<?xml version="1.0" encoding="utf-8"?>
<feed xml:lang="en-us" xmlns="http://www.w3.org/2005/Atom"><title>Simon Willison's Weblog: starlette</title><link href="http://simonwillison.net/" rel="alternate"/><link href="http://simonwillison.net/tags/starlette.atom" rel="self"/><id>http://simonwillison.net/</id><updated>2026-03-23T00:05:00+00:00</updated><author><name>Simon Willison</name></author><entry><title>Starlette 1.0 skill</title><link href="https://simonwillison.net/2026/Mar/23/starlette-1-skill/#atom-tag" rel="alternate"/><published>2026-03-23T00:05:00+00:00</published><updated>2026-03-23T00:05:00+00:00</updated><id>https://simonwillison.net/2026/Mar/23/starlette-1-skill/#atom-tag</id><summary type="html">
    &lt;p&gt;&lt;strong&gt;Research:&lt;/strong&gt; &lt;a href="https://github.com/simonw/research/tree/main/starlette-1-skill#readme"&gt;Starlette 1.0 skill&lt;/a&gt;&lt;/p&gt;
    &lt;p&gt;See &lt;a href="https://simonwillison.net/2026/Mar/22/starlette/"&gt;Experimenting with Starlette 1.0 with Claude skills&lt;/a&gt;.&lt;/p&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/starlette"&gt;starlette&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="starlette"/></entry><entry><title>Experimenting with Starlette 1.0 with Claude skills</title><link href="https://simonwillison.net/2026/Mar/22/starlette/#atom-tag" rel="alternate"/><published>2026-03-22T23:57:44+00:00</published><updated>2026-03-22T23:57:44+00:00</updated><id>https://simonwillison.net/2026/Mar/22/starlette/#atom-tag</id><summary type="html">
    &lt;p&gt;&lt;a href="https://marcelotryle.com/blog/2026/03/22/starlette-10-is-here/"&gt;Starlette 1.0 is out&lt;/a&gt;! This is a really big deal. I think Starlette may be the Python framework with the most usage compared to its relatively low brand recognition because Starlette is the foundation of &lt;a href="https://fastapi.tiangolo.com/"&gt;FastAPI&lt;/a&gt;, which has attracted a huge amount of buzz that seems to have overshadowed Starlette itself.&lt;/p&gt;
&lt;p&gt;Kim Christie started working on Starlette in 2018 and it quickly became my favorite out of the new breed of Python ASGI frameworks. The only reason I didn't use it as the basis for my own &lt;a href="https://datasette.io/"&gt;Datasette&lt;/a&gt; project was that it didn't yet promise stability, and I was determined to provide a stable API for Datasette's own plugins... albeit I still haven't been brave enough to ship my own 1.0 release (after 26 alphas and counting)!&lt;/p&gt;
&lt;p&gt;Then in September 2025 Marcelo Trylesinski &lt;a href="https://github.com/Kludex/starlette/discussions/2997"&gt;announced that Starlette and Uvicorn were transferring to their GitHub account&lt;/a&gt;, in recognition of their many years of contributions and to make it easier for them to receive sponsorship against those projects.&lt;/p&gt;
&lt;p&gt;The 1.0 version has a few breaking changes compared to the 0.x series, described in &lt;a href="https://starlette.dev/release-notes/#100rc1-february-23-2026"&gt;the release notes for 1.0.0rc1&lt;/a&gt; that came out in February.&lt;/p&gt;
&lt;p&gt;The most notable of these is a change to how code runs on startup and shutdown. Previously that was handled by &lt;code&gt;on_startup&lt;/code&gt; and &lt;code&gt;on_shutdown&lt;/code&gt; parameters, but the new system uses a neat &lt;a href="https://starlette.dev/lifespan/"&gt;lifespan&lt;/a&gt; mechanism instead based around an &lt;a href="https://docs.python.org/3/library/contextlib.html#contextlib.asynccontextmanager"&gt;async context manager&lt;/a&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;span class="pl-en"&gt;@&lt;span class="pl-s1"&gt;contextlib&lt;/span&gt;.&lt;span class="pl-c1"&gt;asynccontextmanager&lt;/span&gt;&lt;/span&gt;
&lt;span class="pl-k"&gt;async&lt;/span&gt; &lt;span class="pl-k"&gt;def&lt;/span&gt; &lt;span class="pl-en"&gt;lifespan&lt;/span&gt;(&lt;span class="pl-s1"&gt;app&lt;/span&gt;):
    &lt;span class="pl-k"&gt;async&lt;/span&gt; &lt;span class="pl-k"&gt;with&lt;/span&gt; &lt;span class="pl-en"&gt;some_async_resource&lt;/span&gt;():
        &lt;span class="pl-en"&gt;print&lt;/span&gt;(&lt;span class="pl-s"&gt;"Run at startup!"&lt;/span&gt;)
        &lt;span class="pl-k"&gt;yield&lt;/span&gt;
        &lt;span class="pl-en"&gt;print&lt;/span&gt;(&lt;span class="pl-s"&gt;"Run on shutdown!"&lt;/span&gt;)

&lt;span class="pl-s1"&gt;app&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-en"&gt;Starlette&lt;/span&gt;(
    &lt;span class="pl-s1"&gt;routes&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-s1"&gt;routes&lt;/span&gt;,
    &lt;span class="pl-s1"&gt;lifespan&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-s1"&gt;lifespan&lt;/span&gt;
)&lt;/pre&gt;
&lt;p&gt;If you haven't tried Starlette before it feels to me like an asyncio-native cross between Flask and Django, unsurprising since creator Kim Christie is also responsible for Django REST Framework. Crucially, this means you can write most apps as a single Python file, Flask style.&lt;/p&gt;
&lt;p&gt;This makes it &lt;em&gt;really&lt;/em&gt; easy for LLMs to spit out a working Starlette app from a single prompt.&lt;/p&gt;
&lt;p&gt;There's just one problem there: if 1.0 breaks compatibility with the Starlette code that the models have been trained on, how can we have them generate code that works with 1.0?&lt;/p&gt;
&lt;p&gt;I decided to see if I could get this working &lt;a href="https://simonwillison.net/2025/Oct/16/claude-skills/"&gt;with a Skill&lt;/a&gt;.&lt;/p&gt;
&lt;h4 id="building-a-skill-with-claude"&gt;Building a Skill with Claude&lt;/h4&gt;
&lt;p&gt;Regular Claude Chat on &lt;a href="https://claude.ai/"&gt;claude.ai&lt;/a&gt; has skills, and one of those default skills is the &lt;a href="https://github.com/anthropics/skills/blob/main/skills/skill-creator/SKILL.md"&gt;skill-creator skill&lt;/a&gt;. This means Claude knows how to build its own skills.&lt;/p&gt;
&lt;p&gt;So I started &lt;a href="https://claude.ai/share/b537c340-aea7-49d6-a14d-3134aa1bd957"&gt;a chat session&lt;/a&gt; and told it:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Clone Starlette from GitHub - it just had its 1.0 release. Build a skill markdown document for this release which includes code examples of every feature.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;I didn't even tell it where to find the repo, Starlette is widely enough known that I expected it could find it on its own.&lt;/p&gt;
&lt;p&gt;It ran &lt;code&gt;git clone https://github.com/encode/starlette.git&lt;/code&gt; which is actually the old repository name, but GitHub handles redirects automatically so this worked just fine.&lt;/p&gt;
&lt;p&gt;The &lt;a href="https://github.com/simonw/research/blob/main/starlette-1-skill/SKILL.md"&gt;resulting skill document&lt;/a&gt; looked very thorough to me... and then I noticed a new button at the top I hadn't seen before labelled "Copy to your skills". So I clicked it:&lt;/p&gt;
&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2026/skill-button.jpg" alt="Screenshot of the Claude.ai interface showing a conversation titled &amp;quot;Starlette 1.0 skill document with code examples.&amp;quot; The left panel shows a chat where the user prompted: &amp;quot;Clone Starlette from GitHub - it just had its 1.0 release. Build a skill markdown document for this release which includes code examples of every feature.&amp;quot; Claude's responses include collapsed sections labeled &amp;quot;Strategized cloning repository and documenting comprehensive feature examples,&amp;quot; &amp;quot;Examined version details and surveyed source documentation comprehensively,&amp;quot; and &amp;quot;Synthesized Starlette 1.0 knowledge to construct comprehensive skill documentation,&amp;quot; with intermediate messages like &amp;quot;I'll clone Starlette from GitHub and build a comprehensive skill document. Let me start by reading the skill-creator guide and then cloning the repo,&amp;quot; &amp;quot;Now let me read through all the documentation files to capture every feature:&amp;quot; and &amp;quot;Now I have a thorough understanding of the entire codebase. Let me build the comprehensive skill document.&amp;quot; The right panel shows a skill preview pane with buttons &amp;quot;Copy to your skills&amp;quot; and &amp;quot;Copy&amp;quot; at the top, and a Description section reading: &amp;quot;Build async web applications and APIs with Starlette 1.0, the lightweight ASGI framework for Python. Use this skill whenever a user wants to create an async Python web app, REST API, WebSocket server, or ASGI application using Starlette. Triggers include mentions of 'Starlette', 'ASGI', async Python web frameworks, or requests to build lightweight async APIs, WebSocket services, streaming responses, or middleware pipelines. Also use when the user is working with FastAPI internals (which is built on Starlette), needs ASGI middleware patterns, or wants a minimal async web server&amp;quot; (text truncated)." style="max-width: 100%;" /&gt;&lt;/p&gt;
&lt;p&gt;And now my regular Claude chat has access to that skill!&lt;/p&gt;
&lt;h4 id="a-task-management-demo-app"&gt;A task management demo app&lt;/h4&gt;
&lt;p&gt;I started &lt;a href="https://claude.ai/share/b5285fbc-5849-4939-b473-dcb66f73503b"&gt;a new conversation&lt;/a&gt; and prompted:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Build a task management app with Starlette, it should have projects and tasks and comments and labels&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;And Claude did exactly that, producing a simple GitHub Issues clone using Starlette 1.0, a SQLite database (via &lt;a href="https://github.com/omnilib/aiosqlite"&gt;aiosqlite&lt;/a&gt;) and a Jinja2 template.&lt;/p&gt;
&lt;p&gt;Claude even tested the app manually like this:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;&lt;span class="pl-c1"&gt;cd&lt;/span&gt; /home/claude/taskflow &lt;span class="pl-k"&gt;&amp;amp;&amp;amp;&lt;/span&gt; timeout 5 python -c &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
&lt;span class="pl-s"&gt;import asyncio&lt;/span&gt;
&lt;span class="pl-s"&gt;from database import init_db&lt;/span&gt;
&lt;span class="pl-s"&gt;asyncio.run(init_db())&lt;/span&gt;
&lt;span class="pl-s"&gt;print('DB initialized successfully')&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;2&amp;gt;&amp;amp;1&lt;/span&gt;

pip install httpx --break-system-packages -q \
  &lt;span class="pl-k"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="pl-c1"&gt;cd&lt;/span&gt; /home/claude/taskflow &lt;span class="pl-k"&gt;&amp;amp;&amp;amp;&lt;/span&gt; \
  python -c &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
&lt;span class="pl-s"&gt;from starlette.testclient import TestClient&lt;/span&gt;
&lt;span class="pl-s"&gt;from main import app&lt;/span&gt;
&lt;span class="pl-s"&gt;&lt;/span&gt;
&lt;span class="pl-s"&gt;client = TestClient(app)&lt;/span&gt;
&lt;span class="pl-s"&gt;&lt;/span&gt;
&lt;span class="pl-s"&gt;r = client.get('/api/stats')&lt;/span&gt;
&lt;span class="pl-s"&gt;print('Stats:', r.json())&lt;/span&gt;
&lt;span class="pl-s"&gt;&lt;/span&gt;
&lt;span class="pl-s"&gt;r = client.get('/api/projects')&lt;/span&gt;
&lt;span class="pl-s"&gt;print('Projects:', len(r.json()), 'found')&lt;/span&gt;
&lt;span class="pl-s"&gt;&lt;/span&gt;
&lt;span class="pl-s"&gt;r = client.get('/api/tasks')&lt;/span&gt;
&lt;span class="pl-s"&gt;print('Tasks:', len(r.json()), 'found')&lt;/span&gt;
&lt;span class="pl-s"&gt;&lt;/span&gt;
&lt;span class="pl-s"&gt;r = client.get('/api/labels')&lt;/span&gt;
&lt;span class="pl-s"&gt;print('Labels:', len(r.json()), 'found')&lt;/span&gt;
&lt;span class="pl-s"&gt;&lt;/span&gt;
&lt;span class="pl-s"&gt;r = client.get('/api/tasks/1')&lt;/span&gt;
&lt;span class="pl-s"&gt;t = r.json()&lt;/span&gt;
&lt;span class="pl-s"&gt;print(f'Task 1: &lt;span class="pl-cce"&gt;\"&lt;/span&gt;{t[&lt;span class="pl-cce"&gt;\"&lt;/span&gt;title&lt;span class="pl-cce"&gt;\"&lt;/span&gt;]}&lt;span class="pl-cce"&gt;\"&lt;/span&gt; - {len(t[&lt;span class="pl-cce"&gt;\"&lt;/span&gt;comments&lt;span class="pl-cce"&gt;\"&lt;/span&gt;])} comments, {len(t[&lt;span class="pl-cce"&gt;\"&lt;/span&gt;labels&lt;span class="pl-cce"&gt;\"&lt;/span&gt;])} labels')&lt;/span&gt;
&lt;span class="pl-s"&gt;&lt;/span&gt;
&lt;span class="pl-s"&gt;r = client.post('/api/tasks', json={'title':'Test task','project_id':1,'priority':'high','label_ids':[1,2]})&lt;/span&gt;
&lt;span class="pl-s"&gt;print('Created task:', r.status_code, r.json()['title'])&lt;/span&gt;
&lt;span class="pl-s"&gt;&lt;/span&gt;
&lt;span class="pl-s"&gt;r = client.post('/api/comments', json={'task_id':1,'content':'Test comment'})&lt;/span&gt;
&lt;span class="pl-s"&gt;print('Created comment:', r.status_code)&lt;/span&gt;
&lt;span class="pl-s"&gt;&lt;/span&gt;
&lt;span class="pl-s"&gt;r = client.get('/')&lt;/span&gt;
&lt;span class="pl-s"&gt;print('Homepage:', r.status_code, '- length:', len(r.text))&lt;/span&gt;
&lt;span class="pl-s"&gt;&lt;/span&gt;
&lt;span class="pl-s"&gt;print('\nAll tests passed!')&lt;/span&gt;
&lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;For all of the buzz about Claude Code, it's easy to overlook that Claude itself counts as a coding agent now, fully able to both write and then test the code that it is writing.&lt;/p&gt;
&lt;p&gt;Here's what the resulting app looked like. The code is &lt;a href="https://github.com/simonw/research/blob/main/starlette-1-skill/taskflow"&gt;here in my research repository&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2026/taskflow.jpg" alt="Screenshot of a dark-themed Kanban board app called &amp;quot;TaskFlow&amp;quot; showing the &amp;quot;Website Redesign&amp;quot; project. The left sidebar has sections &amp;quot;OVERVIEW&amp;quot; with &amp;quot;Dashboard&amp;quot;, &amp;quot;All Tasks&amp;quot;, and &amp;quot;Labels&amp;quot;, and &amp;quot;PROJECTS&amp;quot; with &amp;quot;Website Redesign&amp;quot; (1) and &amp;quot;API Platform&amp;quot; (0). The main area has three columns: &amp;quot;TO DO&amp;quot; (0) showing &amp;quot;No tasks&amp;quot;, &amp;quot;IN PROGRESS&amp;quot; (1) with a card titled &amp;quot;Blog about Starlette 1.0&amp;quot; tagged &amp;quot;MEDIUM&amp;quot; and &amp;quot;Documentation&amp;quot;, and &amp;quot;DONE&amp;quot; (0) showing &amp;quot;No tasks&amp;quot;. Top-right buttons read &amp;quot;+ New Task&amp;quot; and &amp;quot;Delete&amp;quot;." style="max-width: 100%;" /&gt;&lt;/p&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/open-source"&gt;open-source&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/asgi"&gt;asgi&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/kim-christie"&gt;kim-christie&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"&gt;claude&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/skills"&gt;skills&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/agentic-engineering"&gt;agentic-engineering&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/starlette"&gt;starlette&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="open-source"/><category term="python"/><category term="ai"/><category term="asgi"/><category term="kim-christie"/><category term="generative-ai"/><category term="llms"/><category term="ai-assisted-programming"/><category term="claude"/><category term="coding-agents"/><category term="skills"/><category term="agentic-engineering"/><category term="starlette"/></entry><entry><title>Streaming File Upload Prototype</title><link href="https://simonwillison.net/2025/Dec/14/streaming-file-upload-prototype/#atom-tag" rel="alternate"/><published>2025-12-14T20:17:00+00:00</published><updated>2025-12-14T20:17:00+00:00</updated><id>https://simonwillison.net/2025/Dec/14/streaming-file-upload-prototype/#atom-tag</id><summary type="html">
    &lt;p&gt;&lt;strong&gt;Research:&lt;/strong&gt; &lt;a href="https://github.com/simonw/research/tree/main/streaming-file-upload-prototype#readme"&gt;Streaming File Upload Prototype&lt;/a&gt;&lt;/p&gt;
    
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/starlette"&gt;starlette&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="starlette"/></entry><entry><title>Building, launching, and scaling ChatGPT Images</title><link href="https://simonwillison.net/2025/May/13/launching-chatgpt-images/#atom-tag" rel="alternate"/><published>2025-05-13T23:52:22+00:00</published><updated>2025-05-13T23:52:22+00:00</updated><id>https://simonwillison.net/2025/May/13/launching-chatgpt-images/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://newsletter.pragmaticengineer.com/p/chatgpt-images"&gt;Building, launching, and scaling ChatGPT Images&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
Gergely Orosz landed a fantastic deep dive interview with OpenAI's Sulman Choudhry (head of engineering, ChatGPT) and Srinivas Narayanan (VP of engineering, OpenAI) to talk about the launch back in March of ChatGPT images - their new image generation mode built on top of multi-modal GPT-4o.&lt;/p&gt;
&lt;p&gt;The feature kept on having new viral spikes, including one that added one million new users in a single hour. They signed up 100 million new users in the first week after the feature's launch.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;When this vertical growth spike started, most of our engineering teams didn't believe it. They assumed there must be something wrong with the metrics.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Under the hood the infrastructure is mostly Python and &lt;a href="https://github.com/fastapi/fastapi"&gt;FastAPI&lt;/a&gt;! I hope they're sponsoring those projects (and &lt;a href="https://github.com/encode/starlette"&gt;Starlette&lt;/a&gt;, which is used by FastAPI under the hood.)&lt;/p&gt;
&lt;p&gt;They're also using some C, and &lt;a href="https://temporal.io/"&gt;Temporal&lt;/a&gt; as a workflow engine. They addressed the early scaling challenge by adding an asynchronous queue to defer the load for their free users (resulting in longer generation times) at peak demand.&lt;/p&gt;
&lt;p&gt;There are plenty more details tucked away behind the firewall, including an exclusive I've not been able to find anywhere else: OpenAI's core engineering principles.&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Ship relentlessly&lt;/strong&gt; - move quickly and continuously improve, without waiting for perfect conditions&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Own the outcome&lt;/strong&gt; - take full responsibility for products, end-to-end&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Follow through&lt;/strong&gt; - finish what is started and ensure the work lands fully&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;I tried getting o4-mini-high to track down a copy of those principles online and was delighted to see it either leak or hallucinate the URL to OpenAI's internal engineering handbook!&lt;/p&gt;
&lt;p&gt;&lt;img alt="Thought for 1m 48s:  Here are the two places where OpenAl's “Ship  relentlessly — Own the outcome — Follow through”  checklist shows up as its core engineering  principles:  1. OpenAl Careers =&amp;gt; Operating Principles  On the Careers page under &amp;quot;Operating Principles,”  OpenAl codifies how every team—and especially  engineering—works together. While the page itself  doesn’t list the bullets verbatim, it's the umbrella  under which these three behavioural norms sit (Ship  relentlessly, Own the outcome, Follow through).  2. OpenAl Internal Engineering Handbook  These three tenets are drawn straight from OpenAl's  internal Engineering Handbook (employee-only), at: https://handbook.openai.com/engineering/principles  (This handbook page isn't publicly crawlable, but it's  the definitive source for how OpenAl's engineers  actually organize their day-to-day work.)" src="https://static.simonwillison.net/static/2025/openai-handbook.jpg" /&gt;&lt;/p&gt;
&lt;p&gt;Gergely has a whole series of posts like this called &lt;a href="https://newsletter.pragmaticengineer.com/t/real-world-engineering-challenges"&gt;Real World Engineering Challenges&lt;/a&gt;, including another one &lt;a href="https://newsletter.pragmaticengineer.com/p/scaling-chatgpt"&gt;on ChatGPT a year ago&lt;/a&gt;.

    &lt;p&gt;&lt;small&gt;&lt;/small&gt;Via &lt;a href="https://twitter.com/GergelyOrosz/status/1922388794377961692"&gt;@GergelyOrosz&lt;/a&gt;&lt;/small&gt;&lt;/p&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/scaling"&gt;scaling&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai"&gt;ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/openai"&gt;openai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/generative-ai"&gt;generative-ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/chatgpt"&gt;chatgpt&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llms"&gt;llms&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/gergely-orosz"&gt;gergely-orosz&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/starlette"&gt;starlette&lt;/a&gt;&lt;/p&gt;



</summary><category term="python"/><category term="scaling"/><category term="ai"/><category term="openai"/><category term="generative-ai"/><category term="chatgpt"/><category term="llms"/><category term="gergely-orosz"/><category term="starlette"/></entry><entry><title>Building Python tools with a one-shot prompt using uv run and Claude Projects</title><link href="https://simonwillison.net/2024/Dec/19/one-shot-python-tools/#atom-tag" rel="alternate"/><published>2024-12-19T07:00:37+00:00</published><updated>2024-12-19T07:00:37+00:00</updated><id>https://simonwillison.net/2024/Dec/19/one-shot-python-tools/#atom-tag</id><summary type="html">
    &lt;p&gt;I've written a lot about how I've been using Claude to build one-shot HTML+JavaScript applications &lt;a href="https://simonwillison.net/tags/claude-artifacts/"&gt;via Claude Artifacts&lt;/a&gt;. I recently started using a similar pattern to create one-shot Python utilities, using a custom Claude Project combined with the dependency management capabilities of &lt;a href="https://github.com/astral-sh/uv"&gt;uv&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;(In LLM jargon a "one-shot" prompt is a prompt that produces the complete desired result on the first attempt. Confusingly it also sometimes means a prompt that includes a single example of the desired output format. Here I'm using the first of those two definitions.)&lt;/p&gt;
&lt;p&gt;I'll start with an example of a tool I built that way.&lt;/p&gt;
&lt;p&gt;I had another round of battle with Amazon S3 today trying to figure out why a file in one of my buckets couldn't be accessed via a public URL.&lt;/p&gt;
&lt;p&gt;Out of frustration I prompted Claude with a variant of the following (&lt;a href="https://gist.github.com/simonw/9f69cf35889b0445b80eeed691d44504"&gt;full transcript here&lt;/a&gt;):&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;I can't access the file at EXAMPLE_S3_URL. Write me a Python CLI tool using Click and boto3 which takes a URL of that form and then uses EVERY single boto3 trick in the book to try and debug why the file is returning a 404&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;It wrote me &lt;a href="https://github.com/simonw/tools/blob/main/python/debug_s3_access.py"&gt;this script&lt;/a&gt;, which gave me exactly what I needed. I ran it like this:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;uv run debug_s3_access.py \
  https://test-public-bucket-simonw.s3.us-east-1.amazonaws.com/0f550b7b28264d7ea2b3d360e3381a95.jpg&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2024/debug-s3.jpg" alt="Terminal screenshot showing S3 access analysis results. Command: '$ uv run http://tools.simonwillison.net/python/debug_s3_access.py url-to-image' followed by detailed output showing bucket exists (Yes), region (default), key exists (Yes), bucket policy (AllowAllGetObject), bucket owner (swillison), versioning (Not enabled), content type (image/jpeg), size (71683 bytes), last modified (2024-12-19 03:43:30+00:00) and public access settings (all False)" style="max-width: 100%;" /&gt;&lt;/p&gt;
&lt;p&gt;You can &lt;a href="https://github.com/simonw/tools/tree/main/python#debug_s3_accesspy"&gt;see the text output here&lt;/a&gt;.&lt;/p&gt;
&lt;h4 id="inline-dependencies-and-uv-run"&gt;Inline dependencies and uv run&lt;/h4&gt;
&lt;p&gt;Crucially, I didn't have to take any extra steps to install any of the dependencies that the script needed. That's because the script starts with this magic comment:&lt;/p&gt;
&lt;pre&gt;&lt;span class="pl-c"&gt;# /// script&lt;/span&gt;
&lt;span class="pl-c"&gt;# requires-python = "&amp;gt;=3.12"&lt;/span&gt;
&lt;span class="pl-c"&gt;# dependencies = [&lt;/span&gt;
&lt;span class="pl-c"&gt;#     "click",&lt;/span&gt;
&lt;span class="pl-c"&gt;#     "boto3",&lt;/span&gt;
&lt;span class="pl-c"&gt;#     "urllib3",&lt;/span&gt;
&lt;span class="pl-c"&gt;#     "rich",&lt;/span&gt;
&lt;span class="pl-c"&gt;# ]&lt;/span&gt;
&lt;span class="pl-c"&gt;# ///&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;This is an example of &lt;a href="https://docs.astral.sh/uv/guides/scripts/#declaring-script-dependencies"&gt;inline script dependencies&lt;/a&gt;, a feature described in &lt;a href="https://peps.python.org/pep-0723/"&gt;PEP 723&lt;/a&gt; and implemented by &lt;code&gt;uv run&lt;/code&gt;. Running the script causes &lt;code&gt;uv&lt;/code&gt; to create a temporary virtual environment with those dependencies installed, a process that takes just a few milliseconds once the &lt;code&gt;uv&lt;/code&gt; cache has been populated.&lt;/p&gt;
&lt;p&gt;This even works if the script is specified by a URL! Anyone with &lt;code&gt;uv&lt;/code&gt; installed can run the following command (provided you trust me not to have replaced the script with something malicious) to debug one of their own S3 buckets:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;uv run http://tools.simonwillison.net/python/debug_s3_access.py \
  https://test-public-bucket-simonw.s3.us-east-1.amazonaws.com/0f550b7b28264d7ea2b3d360e3381a95.jpg&lt;/pre&gt;&lt;/div&gt;
&lt;h4 id="writing-these-with-the-help-of-a-claude-project"&gt;Writing these with the help of a Claude Project&lt;/h4&gt;
&lt;p&gt;The reason I can one-shot scripts like this now is that I've set up a &lt;a href="https://www.anthropic.com/news/projects"&gt;Claude Project&lt;/a&gt; called "Python app". Projects can have custom instructions, and I used those to "teach" Claude how to take advantage of inline script dependencies:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;You write Python tools as single files. They always start with this comment:&lt;/p&gt;
&lt;pre&gt;&lt;span&gt;# /// script&lt;/span&gt;
&lt;span&gt;# requires-python = "&amp;gt;=3.12"&lt;/span&gt;
&lt;span&gt;# ///&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;These files can include dependencies on libraries such as Click. If they do, those dependencies are included in a list like this one in that same comment (here showing two dependencies):&lt;/p&gt;
&lt;pre&gt;&lt;span&gt;# /// script&lt;/span&gt;
&lt;span&gt;# requires-python = "&amp;gt;=3.12"&lt;/span&gt;
&lt;span&gt;# dependencies = [&lt;/span&gt;
&lt;span&gt;#     "click",&lt;/span&gt;
&lt;span&gt;#     "sqlite-utils",&lt;/span&gt;
&lt;span&gt;# ]&lt;/span&gt;
&lt;span&gt;# ///&lt;/span&gt;&lt;/pre&gt;
&lt;/blockquote&gt;
&lt;p&gt;That's everything Claude needs to reliably knock out full-featured Python tools as single scripts which can be run directly using whatever dependencies Claude chose to include.&lt;/p&gt;
&lt;p&gt;I didn't suggest that Claude use &lt;a href="https://github.com/Textualize/rich"&gt;rich&lt;/a&gt; for the &lt;code&gt;debug_s3_access.py&lt;/code&gt; script earlier but it decided to use it anyway!&lt;/p&gt;
&lt;p&gt;I've only recently started experimenting with this pattern but it seems to work &lt;em&gt;really&lt;/em&gt; well. Here's another example - my prompt was:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;Starlette web app that provides an API where you pass in ?url= and it strips all HTML tags and returns just the text, using beautifulsoup&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Here's &lt;a href="https://gist.github.com/simonw/08957a1490ebde1ea38b4a8374989cf8"&gt;the chat transcript&lt;/a&gt; and &lt;a href="https://gist.githubusercontent.com/simonw/08957a1490ebde1ea38b4a8374989cf8/raw/143ee24dc65ca109b094b72e8b8c494369e763d6/strip_html.py"&gt;the raw code it produced&lt;/a&gt;. You can run that server directly on your machine (it uses port 8000) like this:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;uv run https://gist.githubusercontent.com/simonw/08957a1490ebde1ea38b4a8374989cf8/raw/143ee24dc65ca109b094b72e8b8c494369e763d6/strip_html.py&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Then visit &lt;code&gt;http://127.0.0.1:8000/?url=https://simonwillison.net/&lt;/code&gt; to see it in action.&lt;/p&gt;
&lt;h4 id="custom-instructions"&gt;Custom instructions&lt;/h4&gt;
&lt;p&gt;The pattern here that's most interesting to me is using custom instructions or system prompts to show LLMs how to implement new patterns that may not exist in their training data. &lt;code&gt;uv run&lt;/code&gt; is less than a year old, but providing just a short example is enough to get the models to write code that takes advantage of its capabilities.&lt;/p&gt;
&lt;p&gt;I have a similar set of custom instructions I use for creating single page HTML and JavaScript tools, again running in a Claude Project:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Never use React in artifacts - always plain HTML and vanilla JavaScript and CSS with minimal dependencies.&lt;/p&gt;
&lt;p&gt;CSS should be indented with two spaces and should start like this:&lt;/p&gt;
&lt;div class="highlight highlight-text-html-basic"&gt;&lt;pre&gt;&lt;span class="pl-kos"&gt;&amp;lt;&lt;/span&gt;&lt;span class="pl-ent"&gt;style&lt;/span&gt;&lt;span class="pl-kos"&gt;&amp;gt;&lt;/span&gt;
* {
  box-sizing: border-box;
}&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Inputs and textareas should be font size 16px. Font should always prefer Helvetica.&lt;/p&gt;
&lt;p&gt;JavaScript should be two space indents and start like this:&lt;/p&gt;
&lt;div class="highlight highlight-text-html-basic"&gt;&lt;pre&gt;&lt;span class="pl-kos"&gt;&amp;lt;&lt;/span&gt;&lt;span class="pl-ent"&gt;script&lt;/span&gt; &lt;span class="pl-c1"&gt;type&lt;/span&gt;="&lt;span class="pl-s"&gt;module&lt;/span&gt;"&lt;span class="pl-kos"&gt;&amp;gt;&lt;/span&gt;
// code in here should not be indented at the first level&lt;/pre&gt;&lt;/div&gt;
&lt;/blockquote&gt;
&lt;p&gt;Most of the tools on my &lt;a href="https://tools.simonwillison.net/"&gt;tools.simonwillison.net&lt;/a&gt; site were created using versions of this custom instructions prompt.&lt;/p&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/aws"&gt;aws&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/cli"&gt;cli&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/python"&gt;python&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/s3"&gt;s3&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai"&gt;ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/prompt-engineering"&gt;prompt-engineering&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/generative-ai"&gt;generative-ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llms"&gt;llms&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai-assisted-programming"&gt;ai-assisted-programming&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/claude"&gt;claude&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/uv"&gt;uv&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/rich"&gt;rich&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/prompt-to-app"&gt;prompt-to-app&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/starlette"&gt;starlette&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="aws"/><category term="cli"/><category term="python"/><category term="s3"/><category term="ai"/><category term="prompt-engineering"/><category term="generative-ai"/><category term="llms"/><category term="ai-assisted-programming"/><category term="claude"/><category term="claude-artifacts"/><category term="uv"/><category term="rich"/><category term="prompt-to-app"/><category term="starlette"/></entry><entry><title>Python Developers Survey 2023 Results</title><link href="https://simonwillison.net/2024/Sep/3/python-developers-survey-2023/#atom-tag" rel="alternate"/><published>2024-09-03T02:47:45+00:00</published><updated>2024-09-03T02:47:45+00:00</updated><id>https://simonwillison.net/2024/Sep/3/python-developers-survey-2023/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://lp.jetbrains.com/python-developers-survey-2023/"&gt;Python Developers Survey 2023 Results&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
The seventh annual Python survey is out. Here are the things that caught my eye or that I found surprising:&lt;/p&gt;
&lt;p&gt;25% of survey respondents had been programming in Python for less than a year, and 33% had less than a year of professional experience.&lt;/p&gt;
&lt;p&gt;37% of Python developers reported contributing to open-source projects last year - a new question for the survey. This is delightfully high!&lt;/p&gt;
&lt;p&gt;6% of users are still using Python 2. The survey notes:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Almost half of Python 2 holdouts are under 21 years old and a third are students. Perhaps courses are still using Python 2?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;In web frameworks, Flask and Django neck and neck at 33% each, but &lt;a href="https://fastapi.tiangolo.com/"&gt;FastAPI&lt;/a&gt; is a close third at 29%! &lt;a href="https://www.starlette.io/"&gt;Starlette&lt;/a&gt; is at 6%, but that's an under-count because it's the basis for FastAPI.&lt;/p&gt;
&lt;p&gt;The most popular library in "other framework and libraries" was BeautifulSoup with 31%, then Pillow 28%, then &lt;a href="https://github.com/opencv/opencv-python"&gt;OpenCV-Python&lt;/a&gt; at 22% (wow!) and Pydantic at 22%. Tkinter had 17%. These numbers are all a surprise to me.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://docs.pytest.org/en/stable/"&gt;pytest&lt;/a&gt; scores 52% for unit testing, &lt;code&gt;unittest&lt;/code&gt; from the standard library just 25%. I'm glad to see &lt;code&gt;pytest&lt;/code&gt; so widely used, it's my favourite testing tool across any programming language.&lt;/p&gt;
&lt;p&gt;The top cloud providers are AWS, then Google Cloud Platform, then Azure... but &lt;a href="https://www.pythonanywhere.com/"&gt;PythonAnywhere&lt;/a&gt; (11%) took fourth place just ahead of DigitalOcean (10%). And &lt;a href="https://www.alibabacloud.com/"&gt;Alibaba Cloud&lt;/a&gt; is a new entrant in sixth place (after Heroku) with 4%. Heroku's ending of its free plan dropped them from 14% in 2021 to 7% now.&lt;/p&gt;
&lt;p&gt;Linux and Windows equal at 55%, macOS is at 29%. This was one of many multiple-choice questions that could add up to more than 100%.&lt;/p&gt;
&lt;p&gt;In databases, SQLite usage was trending down - 38% in 2021 to 34% for 2023, but still in second place behind PostgreSQL, stable at 43%.&lt;/p&gt;
&lt;p&gt;The survey incorporates quotes from different Python experts responding to the numbers, it's worth &lt;a href="https://lp.jetbrains.com/python-developers-survey-2023/"&gt;reading through the whole thing&lt;/a&gt;.

    &lt;p&gt;&lt;small&gt;&lt;/small&gt;Via &lt;a href="https://pyfound.blogspot.com/2024/08/python-developers-survey-2023-results.html"&gt;PSF news&lt;/a&gt;&lt;/small&gt;&lt;/p&gt;


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/open-source"&gt;open-source&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/postgresql"&gt;postgresql&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/python"&gt;python&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/sqlite"&gt;sqlite&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/surveys"&gt;surveys&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/pytest"&gt;pytest&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/psf"&gt;psf&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/pydantic"&gt;pydantic&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/starlette"&gt;starlette&lt;/a&gt;&lt;/p&gt;



</summary><category term="open-source"/><category term="postgresql"/><category term="python"/><category term="sqlite"/><category term="surveys"/><category term="pytest"/><category term="psf"/><category term="pydantic"/><category term="starlette"/></entry><entry><title>AI-enhanced development makes me more ambitious with my projects</title><link href="https://simonwillison.net/2023/Mar/27/ai-enhanced-development/#atom-tag" rel="alternate"/><published>2023-03-27T14:38:56+00:00</published><updated>2023-03-27T14:38:56+00:00</updated><id>https://simonwillison.net/2023/Mar/27/ai-enhanced-development/#atom-tag</id><summary type="html">
    &lt;p&gt;The thing I'm most excited about in our weird new AI-enhanced reality is the way it allows me to be more &lt;em&gt;ambitious&lt;/em&gt; with my projects.&lt;/p&gt;
&lt;p&gt;As an experienced developer, ChatGPT (and GitHub Copilot) save me an enormous amount of "figuring things out" time. For everything from writing a for loop in Bash to remembering how to make a cross-domain CORS request in JavaScript - I don't need to even look things up any more, I can just prompt it and get the right answer 80% of the time.&lt;/p&gt;
&lt;p&gt;This doesn't just make me more productive: it lowers my bar for when a project is worth investing time in at all.&lt;/p&gt;
&lt;p&gt;In the past I've had plenty of ideas for projects which I've ruled out because they would take a day - or days - of work to get to a point where they're useful. I have enough other stuff to build already!&lt;/p&gt;
&lt;p&gt;But if ChatGPT can drop that down to an hour or less, those projects can suddenly become viable.&lt;/p&gt;
&lt;p&gt;Which means I'm building all sorts of weird and interesting little things that previously I wouldn't have invested the time in.&lt;/p&gt;
&lt;p&gt;I'll describe my latest one of these mini-projects in detail.&lt;/p&gt;
&lt;h4 id="archive-system"&gt;Using ChatGPT to build a system to archive ChatGPT messages&lt;/h4&gt;
&lt;p&gt;I use ChatGPT a lot, and I want my own archive of conversations I've had with it.&lt;/p&gt;
&lt;p&gt;There's no API for retrieving previous conversations at the moment, but if you open up the browser DevTools and watch the network tab you can see it loading JSON any time you navigate to an older conversation.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2023/chatgpt-chrome-inspector-cropped.jpg" alt="ChatGPT with the Chrome inspector open. On the left is a conversation, on the right the Chrome inspector is showing the underlying JSON that was fetched for that conversation in the Network pane." style="max-width: 100%;" /&gt;&lt;/p&gt;
&lt;p&gt;This gave me an idea for a solution...&lt;/p&gt;
&lt;p&gt;What if I could run some JavaScript on the ChatGPT site that grabbed that JSON data and sent it off to my own server?&lt;/p&gt;
&lt;p&gt;I couldn't find the raw data in JavaScript variables available to the console, so I came up with an alternative approach: could I patch the default &lt;code&gt;fetch()&lt;/code&gt; function to intercept calls made by the ChatGPT code to that endpoint, and intercept the data that way?&lt;/p&gt;
&lt;p&gt;Normally this is where the project would end. Figuring out how to patch &lt;code&gt;fetch()&lt;/code&gt; like that is non-trivial - where I'm using the software engineer's definition of "non-trivial" as meaning "I don't know how to do that off the top of my head". I'd have to do some thinking and research! I'm already tempted to drop this idea and work on something else.&lt;/p&gt;
&lt;p&gt;But now I have ChatGPT... so I prompted it:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Write a new JavaScript fetch() function which calls the old one but also saves the JSON that is retrieved - if it is used to retrieve JSON - in a window.fetchedData object&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;It gave me some code, but it wasn't quite what I wanted. So I added a follow-up prompt:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;No I need to replace the old window.fetch() function entirely - I want to patch it so that I can intercept calls made by other existing code&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;And it produced the following:&lt;/p&gt;
&lt;div class="highlight highlight-source-js"&gt;&lt;pre&gt;&lt;span class="pl-c"&gt;// Define a global object to store fetched JSON data&lt;/span&gt;
&lt;span class="pl-smi"&gt;window&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;fetchedData&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-c"&gt;// Store a reference to the original fetch function&lt;/span&gt;
&lt;span class="pl-k"&gt;const&lt;/span&gt; &lt;span class="pl-s1"&gt;originalFetch&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-smi"&gt;window&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;fetch&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;

&lt;span class="pl-c"&gt;// Define a new fetch function that wraps the original fetch&lt;/span&gt;
&lt;span class="pl-smi"&gt;window&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;fetch&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-k"&gt;async&lt;/span&gt; &lt;span class="pl-k"&gt;function&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;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;try&lt;/span&gt; &lt;span class="pl-kos"&gt;{&lt;/span&gt;
    &lt;span class="pl-c"&gt;// Call the original fetch function&lt;/span&gt;
    &lt;span class="pl-k"&gt;const&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-k"&gt;await&lt;/span&gt; &lt;span class="pl-s1"&gt;originalFetch&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-c"&gt;// Check if the response has a JSON content type&lt;/span&gt;
    &lt;span class="pl-k"&gt;const&lt;/span&gt; &lt;span class="pl-s1"&gt;contentType&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;response&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;headers&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;get&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s"&gt;'content-type'&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;contentType&lt;/span&gt; &lt;span class="pl-c1"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="pl-s1"&gt;contentType&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;includes&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s"&gt;'application/json'&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt; &lt;span class="pl-kos"&gt;{&lt;/span&gt;
      &lt;span class="pl-c"&gt;// If the response is JSON, clone the response so we can read it twice&lt;/span&gt;
      &lt;span class="pl-k"&gt;const&lt;/span&gt; &lt;span class="pl-s1"&gt;responseClone&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;response&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;clone&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-c"&gt;// Parse the JSON data and save it to the fetchedData object&lt;/span&gt;
      &lt;span class="pl-k"&gt;const&lt;/span&gt; &lt;span class="pl-s1"&gt;jsonData&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-k"&gt;await&lt;/span&gt; &lt;span class="pl-s1"&gt;responseClone&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;json&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
      &lt;span class="pl-smi"&gt;window&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;fetchedData&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-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;jsonData&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
    &lt;span class="pl-kos"&gt;}&lt;/span&gt;

    &lt;span class="pl-c"&gt;// Return the original response&lt;/span&gt;
    &lt;span class="pl-k"&gt;return&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-kos"&gt;}&lt;/span&gt; &lt;span class="pl-k"&gt;catch&lt;/span&gt; &lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;error&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt; &lt;span class="pl-kos"&gt;{&lt;/span&gt;
    &lt;span class="pl-c"&gt;// Handle any errors that occur during the fetch&lt;/span&gt;
    &lt;span class="pl-smi"&gt;console&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;error&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s"&gt;'Error fetching and saving JSON:'&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-s1"&gt;error&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-s1"&gt;error&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
  &lt;span class="pl-kos"&gt;}&lt;/span&gt;
&lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;That's exactly what I wanted!&lt;/p&gt;
&lt;p&gt;I pasted it into the DevTools console on the ChatGPT site and clicked on some links. It worked as intended.&lt;/p&gt;
&lt;p&gt;Here's &lt;a href="https://gist.github.com/simonw/8fbade64a3606645243b3438930b8159"&gt;that full ChatGPT transcript&lt;/a&gt; that got me to this point.&lt;/p&gt;
&lt;p&gt;So now I have a prototype! I've proved to myself that I can intercept the JSON data fetched by ChatGPT's own web application code.&lt;/p&gt;
&lt;p&gt;I only wanted to run my code on requests that matched &lt;code&gt;https://chat.openai.com/backend-api/conversation/...&lt;/code&gt; - I could write a regex for that, but I'd have to remember to escape the necessary characters. ChatGPT &lt;a href="https://gist.github.com/simonw/103972b7eb0c1f45d536d0a0c04cb508"&gt;did that for me&lt;/a&gt; too:&lt;/p&gt;
&lt;div class="highlight highlight-source-js"&gt;&lt;pre&gt;&lt;span class="pl-k"&gt;const&lt;/span&gt; &lt;span class="pl-s1"&gt;pattern&lt;/span&gt; &lt;span class="pl-c1"&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;https:&lt;span class="pl-cce"&gt;\/&lt;/span&gt;&lt;span class="pl-cce"&gt;\/&lt;/span&gt;chat&lt;span class="pl-cce"&gt;\.&lt;/span&gt;openai&lt;span class="pl-cce"&gt;\.&lt;/span&gt;com&lt;span class="pl-cce"&gt;\/&lt;/span&gt;backend-api&lt;span class="pl-cce"&gt;\/&lt;/span&gt;conversation&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;/pre&gt;&lt;/div&gt;
&lt;p&gt;So now I have the key building blocks I need for my solution: I can intercept JSON fetches and then filter to just the data from the endpoint I care about.&lt;/p&gt;
&lt;h4 id="cors-support"&gt;But I need CORS support&lt;/h4&gt;
&lt;p&gt;My plan was to have my intercepting &lt;code&gt;fetch()&lt;/code&gt; call POST the JSON data to my own &lt;a href="https://datasette.cloud/"&gt;Datasette Cloud&lt;/a&gt; instance, similar to how I record scraped Hacker News listings as described in &lt;a href="https://simonwillison.net/2022/Dec/2/datasette-write-api/"&gt;this post about Datasette's new write API&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;One big problem: this means that code running on the &lt;code&gt;chat.openai.com&lt;/code&gt; domain needs to POST JSON to another server. And that means the other server needs to be serving &lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS"&gt;CORS headers&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Datasette Cloud doesn't (yet) support CORS - and I wasn't about to implement a new production feature there just so I could solve a problem for this prototype.&lt;/p&gt;
&lt;p&gt;What I really needed was some kind of CORS proxy... a URL running somewhere which forwards traffic on to Datasette Cloud but adds CORS headers to enable it to be called from elsewhere.&lt;/p&gt;
&lt;p&gt;This represents another potential blocking point: do I really want to implement an entire proxy web application just for this little project?&lt;/p&gt;
&lt;p&gt;Here's my next ChatGPT prompt:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Write a web app in python Starlette which has CORS enabled - including for the auth header - and forwards all incoming requests to any path to another server host specified in an environment variable&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;I like &lt;a href="https://www.starlette.io/"&gt;Starlette&lt;/a&gt; and I know it has CORS support and is great for writing proxies. I was hopeful that GPT-4 had seen its documentation before the September 2021 cut-off date.&lt;/p&gt;
&lt;p&gt;ChatGPT wrote me some very solid code! It used the &lt;code&gt;python-dotenv&lt;/code&gt; package though, and I didn't feel like using that dependency. So I added:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Rewrite that to use os.environ&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The exception handling looked wrong to me, so I said:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Catch httpx.HTTPError as the only exception you care about&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;And after just those three prompts it produced the following:&lt;/p&gt;
&lt;pre&gt;&lt;span class="pl-k"&gt;import&lt;/span&gt; &lt;span class="pl-s1"&gt;os&lt;/span&gt;
&lt;span class="pl-k"&gt;from&lt;/span&gt; &lt;span class="pl-s1"&gt;starlette&lt;/span&gt;.&lt;span class="pl-s1"&gt;applications&lt;/span&gt; &lt;span class="pl-k"&gt;import&lt;/span&gt; &lt;span class="pl-v"&gt;Starlette&lt;/span&gt;
&lt;span class="pl-k"&gt;from&lt;/span&gt; &lt;span class="pl-s1"&gt;starlette&lt;/span&gt;.&lt;span class="pl-s1"&gt;middleware&lt;/span&gt; &lt;span class="pl-k"&gt;import&lt;/span&gt; &lt;span class="pl-v"&gt;Middleware&lt;/span&gt;
&lt;span class="pl-k"&gt;from&lt;/span&gt; &lt;span class="pl-s1"&gt;starlette&lt;/span&gt;.&lt;span class="pl-s1"&gt;middleware&lt;/span&gt;.&lt;span class="pl-s1"&gt;cors&lt;/span&gt; &lt;span class="pl-k"&gt;import&lt;/span&gt; &lt;span class="pl-v"&gt;CORSMiddleware&lt;/span&gt;
&lt;span class="pl-k"&gt;from&lt;/span&gt; &lt;span class="pl-s1"&gt;starlette&lt;/span&gt;.&lt;span class="pl-s1"&gt;routing&lt;/span&gt; &lt;span class="pl-k"&gt;import&lt;/span&gt; &lt;span class="pl-v"&gt;Route&lt;/span&gt;
&lt;span class="pl-k"&gt;from&lt;/span&gt; &lt;span class="pl-s1"&gt;starlette&lt;/span&gt;.&lt;span class="pl-s1"&gt;requests&lt;/span&gt; &lt;span class="pl-k"&gt;import&lt;/span&gt; &lt;span class="pl-v"&gt;Request&lt;/span&gt;
&lt;span class="pl-k"&gt;from&lt;/span&gt; &lt;span class="pl-s1"&gt;starlette&lt;/span&gt;.&lt;span class="pl-s1"&gt;responses&lt;/span&gt; &lt;span class="pl-k"&gt;import&lt;/span&gt; &lt;span class="pl-v"&gt;JSONResponse&lt;/span&gt;
&lt;span class="pl-k"&gt;import&lt;/span&gt; &lt;span class="pl-s1"&gt;httpx&lt;/span&gt;

&lt;span class="pl-v"&gt;TARGET_SERVER_HOST&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;os&lt;/span&gt;.&lt;span class="pl-s1"&gt;environ&lt;/span&gt;.&lt;span class="pl-en"&gt;get&lt;/span&gt;(&lt;span class="pl-s"&gt;"TARGET_SERVER_HOST"&lt;/span&gt;)

&lt;span class="pl-s1"&gt;middleware&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; [
    &lt;span class="pl-v"&gt;Middleware&lt;/span&gt;(
        &lt;span class="pl-v"&gt;CORSMiddleware&lt;/span&gt;,
        &lt;span class="pl-s1"&gt;allow_origins&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;[&lt;span class="pl-s"&gt;"*"&lt;/span&gt;],
        &lt;span class="pl-s1"&gt;allow_credentials&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-c1"&gt;True&lt;/span&gt;,
        &lt;span class="pl-s1"&gt;allow_methods&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;[&lt;span class="pl-s"&gt;"*"&lt;/span&gt;],
        &lt;span class="pl-s1"&gt;allow_headers&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;[&lt;span class="pl-s"&gt;"*"&lt;/span&gt;],
    )
]

&lt;span class="pl-s1"&gt;app&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-v"&gt;Starlette&lt;/span&gt;(&lt;span class="pl-s1"&gt;middleware&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-s1"&gt;middleware&lt;/span&gt;)

&lt;span class="pl-k"&gt;async&lt;/span&gt; &lt;span class="pl-k"&gt;def&lt;/span&gt; &lt;span class="pl-en"&gt;forward_request&lt;/span&gt;(&lt;span class="pl-s1"&gt;request&lt;/span&gt;: &lt;span class="pl-v"&gt;Request&lt;/span&gt;):
    &lt;span class="pl-k"&gt;try&lt;/span&gt;:
        &lt;span class="pl-k"&gt;async&lt;/span&gt; &lt;span class="pl-k"&gt;with&lt;/span&gt; &lt;span class="pl-s1"&gt;httpx&lt;/span&gt;.&lt;span class="pl-v"&gt;AsyncClient&lt;/span&gt;() &lt;span class="pl-k"&gt;as&lt;/span&gt; &lt;span class="pl-s1"&gt;client&lt;/span&gt;:
            &lt;span class="pl-s1"&gt;target_url&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s"&gt;f"&lt;span class="pl-s1"&gt;&lt;span class="pl-kos"&gt;{&lt;/span&gt;&lt;span class="pl-v"&gt;TARGET_SERVER_HOST&lt;/span&gt;&lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;/span&gt;&lt;span class="pl-s1"&gt;&lt;span class="pl-kos"&gt;{&lt;/span&gt;&lt;span class="pl-s1"&gt;request&lt;/span&gt;.&lt;span class="pl-s1"&gt;url&lt;/span&gt;.&lt;span class="pl-s1"&gt;path&lt;/span&gt;&lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;/span&gt;"&lt;/span&gt;
            &lt;span class="pl-s1"&gt;method&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;request&lt;/span&gt;.&lt;span class="pl-s1"&gt;method&lt;/span&gt;
            &lt;span class="pl-s1"&gt;headers&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-en"&gt;dict&lt;/span&gt;(&lt;span class="pl-s1"&gt;request&lt;/span&gt;.&lt;span class="pl-s1"&gt;headers&lt;/span&gt;)
            &lt;span class="pl-k"&gt;if&lt;/span&gt; &lt;span class="pl-s"&gt;"host"&lt;/span&gt; &lt;span class="pl-c1"&gt;in&lt;/span&gt; &lt;span class="pl-s1"&gt;headers&lt;/span&gt;:
                &lt;span class="pl-k"&gt;del&lt;/span&gt; &lt;span class="pl-s1"&gt;headers&lt;/span&gt;[&lt;span class="pl-s"&gt;"host"&lt;/span&gt;]
            &lt;span class="pl-k"&gt;if&lt;/span&gt; &lt;span class="pl-s1"&gt;request&lt;/span&gt;.&lt;span class="pl-s1"&gt;method&lt;/span&gt; &lt;span class="pl-c1"&gt;in&lt;/span&gt; [&lt;span class="pl-s"&gt;"POST"&lt;/span&gt;, &lt;span class="pl-s"&gt;"PUT"&lt;/span&gt;, &lt;span class="pl-s"&gt;"PATCH"&lt;/span&gt;]:
                &lt;span class="pl-s1"&gt;request_body&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-k"&gt;await&lt;/span&gt; &lt;span class="pl-s1"&gt;request&lt;/span&gt;.&lt;span class="pl-en"&gt;json&lt;/span&gt;()
            &lt;span class="pl-k"&gt;else&lt;/span&gt;:
                &lt;span class="pl-s1"&gt;request_body&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-c1"&gt;None&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-k"&gt;await&lt;/span&gt; &lt;span class="pl-s1"&gt;client&lt;/span&gt;.&lt;span class="pl-en"&gt;request&lt;/span&gt;(
                &lt;span class="pl-s1"&gt;method&lt;/span&gt;, &lt;span class="pl-s1"&gt;target_url&lt;/span&gt;, &lt;span class="pl-s1"&gt;headers&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-s1"&gt;headers&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-s1"&gt;request_body&lt;/span&gt;
            )
            &lt;span class="pl-k"&gt;return&lt;/span&gt; &lt;span class="pl-v"&gt;JSONResponse&lt;/span&gt;(&lt;span class="pl-s1"&gt;response&lt;/span&gt;.&lt;span class="pl-en"&gt;json&lt;/span&gt;(), &lt;span class="pl-s1"&gt;status_code&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-s1"&gt;response&lt;/span&gt;.&lt;span class="pl-s1"&gt;status_code&lt;/span&gt;)
    &lt;span class="pl-k"&gt;except&lt;/span&gt; &lt;span class="pl-s1"&gt;httpx&lt;/span&gt;.&lt;span class="pl-v"&gt;HTTPError&lt;/span&gt; &lt;span class="pl-k"&gt;as&lt;/span&gt; &lt;span class="pl-s1"&gt;e&lt;/span&gt;:
        &lt;span class="pl-k"&gt;return&lt;/span&gt; &lt;span class="pl-v"&gt;JSONResponse&lt;/span&gt;({&lt;span class="pl-s"&gt;"error"&lt;/span&gt;: &lt;span class="pl-en"&gt;str&lt;/span&gt;(&lt;span class="pl-s1"&gt;e&lt;/span&gt;)}, &lt;span class="pl-s1"&gt;status_code&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-c1"&gt;500&lt;/span&gt;)

&lt;span class="pl-s1"&gt;app&lt;/span&gt;.&lt;span class="pl-en"&gt;add_route&lt;/span&gt;(&lt;span class="pl-s"&gt;"/{path:path}"&lt;/span&gt;, &lt;span class="pl-s1"&gt;forward_request&lt;/span&gt;, &lt;span class="pl-s1"&gt;methods&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;[&lt;span class="pl-s"&gt;"GET"&lt;/span&gt;, &lt;span class="pl-s"&gt;"POST"&lt;/span&gt;, &lt;span class="pl-s"&gt;"PUT"&lt;/span&gt;, &lt;span class="pl-s"&gt;"PATCH"&lt;/span&gt;, &lt;span class="pl-s"&gt;"DELETE"&lt;/span&gt;])

&lt;span class="pl-k"&gt;if&lt;/span&gt; &lt;span class="pl-s1"&gt;__name__&lt;/span&gt; &lt;span class="pl-c1"&gt;==&lt;/span&gt; &lt;span class="pl-s"&gt;"__main__"&lt;/span&gt;:
    &lt;span class="pl-k"&gt;import&lt;/span&gt; &lt;span class="pl-s1"&gt;uvicorn&lt;/span&gt;

    &lt;span class="pl-s1"&gt;uvicorn&lt;/span&gt;.&lt;span class="pl-en"&gt;run&lt;/span&gt;(&lt;span class="pl-s1"&gt;app&lt;/span&gt;, &lt;span class="pl-s1"&gt;host&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-s"&gt;"0.0.0.0"&lt;/span&gt;, &lt;span class="pl-s1"&gt;port&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-c1"&gt;8000&lt;/span&gt;)&lt;/pre&gt;
&lt;p&gt;I tested this locally... and spotted a bug. It would fail with a 500 error if the incoming request included a &lt;code&gt;content-length&lt;/code&gt; header. I fixed that myself by adding this code:&lt;/p&gt;
&lt;pre&gt;&lt;span class="pl-k"&gt;if&lt;/span&gt; &lt;span class="pl-s"&gt;"content-length"&lt;/span&gt; &lt;span class="pl-c1"&gt;in&lt;/span&gt; &lt;span class="pl-s1"&gt;headers&lt;/span&gt;:
    &lt;span class="pl-k"&gt;del&lt;/span&gt; &lt;span class="pl-s1"&gt;headers&lt;/span&gt;[&lt;span class="pl-s"&gt;"content-length"&lt;/span&gt;]&lt;/pre&gt;
&lt;p&gt;My &lt;a href="https://gist.github.com/simonw/e0a5368d8b465febefe6d4ae6f47f2da"&gt;finished code is here&lt;/a&gt;. Here's &lt;a href="https://gist.github.com/simonw/d95809e5a5c1441281cd5585270ab834"&gt;the ChatGPT transcript&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;I deployed this to Vercel using the method &lt;a href="https://til.simonwillison.net/zeit-now/python-asgi-on-now-v2"&gt;described in this TIL&lt;/a&gt; - and now I had a working proxy server.&lt;/p&gt;
&lt;h4 id="creating-tables"&gt;Creating the tables and a token&lt;/h4&gt;
&lt;p&gt;ChatGPT had got me a long way. The rest of my implementation was now a small enough lift that I could quickly finish it by myself.&lt;/p&gt;
&lt;p&gt;I created two tables in my Datasette Cloud instance by executing the following SQL (using the &lt;a href="https://datasette.io/plugins/datasette-write"&gt;datasette-write&lt;/a&gt; plugin):&lt;/p&gt;
&lt;div class="highlight highlight-source-sql"&gt;&lt;pre&gt;&lt;span class="pl-k"&gt;create&lt;/span&gt; &lt;span class="pl-k"&gt;table&lt;/span&gt; &lt;span class="pl-en"&gt;chatgpt_conversation&lt;/span&gt; (
  id &lt;span class="pl-k"&gt;text&lt;/span&gt; &lt;span class="pl-k"&gt;primary key&lt;/span&gt;,
  title &lt;span class="pl-k"&gt;text&lt;/span&gt;,
  create_time float,
  moderation_results &lt;span class="pl-k"&gt;text&lt;/span&gt;,
  current_node &lt;span class="pl-k"&gt;text&lt;/span&gt;,
  plugin_ids &lt;span class="pl-k"&gt;text&lt;/span&gt;
);
&lt;span class="pl-k"&gt;create&lt;/span&gt; &lt;span class="pl-k"&gt;table&lt;/span&gt; &lt;span class="pl-en"&gt;chatgpt_message&lt;/span&gt; (
  id &lt;span class="pl-k"&gt;text&lt;/span&gt; &lt;span class="pl-k"&gt;primary key&lt;/span&gt;,
  conversation_id &lt;span class="pl-k"&gt;text&lt;/span&gt; &lt;span class="pl-k"&gt;references&lt;/span&gt; chatgpt_conversation(id),
  author_role &lt;span class="pl-k"&gt;text&lt;/span&gt;,
  author_metadata &lt;span class="pl-k"&gt;text&lt;/span&gt;,
  create_time float,
  content &lt;span class="pl-k"&gt;text&lt;/span&gt;,
  end_turn &lt;span class="pl-k"&gt;integer&lt;/span&gt;,
  weight float,
  metadata &lt;span class="pl-k"&gt;text&lt;/span&gt;,
  recipient &lt;span class="pl-k"&gt;text&lt;/span&gt;
);&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Then I made myself a Datasette API token with permission to &lt;code&gt;insert-row&lt;/code&gt; and &lt;code&gt;update-row&lt;/code&gt; just for those two tables, using the new &lt;a href="https://simonwillison.net/2022/Dec/15/datasette-1a2/#finely-grained-permissions"&gt;finely grained permissions feature&lt;/a&gt; in the 1.0 alpha series.&lt;/p&gt;
&lt;p&gt;The last step was to combine this all together into a &lt;code&gt;fetch()&lt;/code&gt; function that did the right thing. I wrote this code by hand, using the ChatGPT prototype as a starting point:&lt;/p&gt;
&lt;div class="highlight highlight-source-js"&gt;&lt;pre&gt;&lt;span class="pl-k"&gt;const&lt;/span&gt; &lt;span class="pl-c1"&gt;TOKEN&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s"&gt;"dstok_my-token-here"&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;

&lt;span class="pl-c"&gt;// Store a reference to the original fetch function&lt;/span&gt;
&lt;span class="pl-smi"&gt;window&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;originalFetch&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-smi"&gt;window&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;fetch&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;

&lt;span class="pl-c"&gt;// Define a new fetch function that wraps the original fetch&lt;/span&gt;

&lt;span class="pl-smi"&gt;window&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;fetch&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-k"&gt;async&lt;/span&gt; &lt;span class="pl-k"&gt;function&lt;/span&gt; &lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;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;try&lt;/span&gt; &lt;span class="pl-kos"&gt;{&lt;/span&gt;
    &lt;span class="pl-c"&gt;// Call the original fetch function&lt;/span&gt;
    &lt;span class="pl-k"&gt;const&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-k"&gt;await&lt;/span&gt; &lt;span class="pl-en"&gt;originalFetch&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-c"&gt;// Check if the response has a JSON content type&lt;/span&gt;
    &lt;span class="pl-k"&gt;const&lt;/span&gt; &lt;span class="pl-s1"&gt;contentType&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;response&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;headers&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;get&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s"&gt;"content-type"&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;contentType&lt;/span&gt; &lt;span class="pl-c1"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="pl-s1"&gt;contentType&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;includes&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s"&gt;"application/json"&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt; &lt;span class="pl-kos"&gt;{&lt;/span&gt;
      &lt;span class="pl-c"&gt;// If the response is JSON, clone the response so we can read it twice&lt;/span&gt;
      &lt;span class="pl-k"&gt;const&lt;/span&gt; &lt;span class="pl-s1"&gt;responseClone&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;response&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;clone&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-c"&gt;// Parse the JSON data and save it to the fetchedData object&lt;/span&gt;
      &lt;span class="pl-k"&gt;const&lt;/span&gt; &lt;span class="pl-s1"&gt;jsonData&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-k"&gt;await&lt;/span&gt; &lt;span class="pl-s1"&gt;responseClone&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;json&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
      &lt;span class="pl-c"&gt;// NOW: if url for https://chat.openai.com/backend-api/conversation/...&lt;/span&gt;
      &lt;span class="pl-c"&gt;// do something very special with it&lt;/span&gt;
      &lt;span class="pl-k"&gt;const&lt;/span&gt; &lt;span class="pl-s1"&gt;pattern&lt;/span&gt; &lt;span class="pl-c1"&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;https:&lt;span class="pl-cce"&gt;\/&lt;/span&gt;&lt;span class="pl-cce"&gt;\/&lt;/span&gt;chat&lt;span class="pl-cce"&gt;\.&lt;/span&gt;openai&lt;span class="pl-cce"&gt;\.&lt;/span&gt;com&lt;span class="pl-cce"&gt;\/&lt;/span&gt;backend-api&lt;span class="pl-cce"&gt;\/&lt;/span&gt;conversation&lt;span class="pl-cce"&gt;\/&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-kos"&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-k"&gt;const&lt;/span&gt; &lt;span class="pl-s1"&gt;match&lt;/span&gt; &lt;span class="pl-c1"&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-en"&gt;match&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;pattern&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;match&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt; &lt;span class="pl-kos"&gt;{&lt;/span&gt;
        &lt;span class="pl-k"&gt;const&lt;/span&gt; &lt;span class="pl-s1"&gt;conversationId&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;match&lt;/span&gt;&lt;span class="pl-kos"&gt;[&lt;/span&gt;&lt;span class="pl-c1"&gt;1&lt;/span&gt;&lt;span class="pl-kos"&gt;]&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
        &lt;span class="pl-smi"&gt;console&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;log&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s"&gt;"conversationId"&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-s1"&gt;conversationId&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
        &lt;span class="pl-smi"&gt;console&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;log&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s"&gt;"jsonData"&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-s1"&gt;jsonData&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
        &lt;span class="pl-k"&gt;const&lt;/span&gt; &lt;span class="pl-s1"&gt;conversation&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;id&lt;/span&gt;: &lt;span class="pl-s1"&gt;conversationId&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;
          &lt;span class="pl-c1"&gt;title&lt;/span&gt;: &lt;span class="pl-s1"&gt;jsonData&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;title&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;
          &lt;span class="pl-c1"&gt;create_time&lt;/span&gt;: &lt;span class="pl-s1"&gt;jsonData&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;create_time&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;
          &lt;span class="pl-c1"&gt;moderation_results&lt;/span&gt;: &lt;span class="pl-c1"&gt;JSON&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;stringify&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;jsonData&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;moderation_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-c1"&gt;current_node&lt;/span&gt;: &lt;span class="pl-s1"&gt;jsonData&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;current_node&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;
          &lt;span class="pl-c1"&gt;plugin_ids&lt;/span&gt;: &lt;span class="pl-c1"&gt;JSON&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;stringify&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;jsonData&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;plugin_ids&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;
        &lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
        &lt;span class="pl-en"&gt;fetch&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;
          &lt;span class="pl-s"&gt;"https://starlette-cors-proxy-simonw-datasette.vercel.app/data/chatgpt_conversation/-/insert"&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;
          &lt;span class="pl-kos"&gt;{&lt;/span&gt;
            &lt;span class="pl-c1"&gt;method&lt;/span&gt;: &lt;span class="pl-s"&gt;"POST"&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;
            &lt;span class="pl-c1"&gt;headers&lt;/span&gt;: &lt;span class="pl-kos"&gt;{&lt;/span&gt;
              &lt;span class="pl-s"&gt;"Content-Type"&lt;/span&gt;: &lt;span class="pl-s"&gt;"application/json"&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 class="pl-s1"&gt;&lt;span class="pl-kos"&gt;${&lt;/span&gt;&lt;span class="pl-c1"&gt;TOKEN&lt;/span&gt;&lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;/span&gt;`&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;
            &lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;
            &lt;span class="pl-c1"&gt;mode&lt;/span&gt;: &lt;span class="pl-s"&gt;"cors"&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;
            &lt;span class="pl-c1"&gt;body&lt;/span&gt;: &lt;span class="pl-c1"&gt;JSON&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;stringify&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-kos"&gt;{&lt;/span&gt;
              &lt;span class="pl-c1"&gt;row&lt;/span&gt;: &lt;span class="pl-s1"&gt;conversation&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;
              &lt;span class="pl-c1"&gt;replace&lt;/span&gt;: &lt;span class="pl-c1"&gt;true&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;
            &lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;
          &lt;span class="pl-kos"&gt;}&lt;/span&gt;
        &lt;span class="pl-kos"&gt;)&lt;/span&gt;
          &lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;then&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;d&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt; &lt;span class="pl-c1"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="pl-s1"&gt;d&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;json&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;
          &lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;then&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;d&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt; &lt;span class="pl-c1"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="pl-smi"&gt;console&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;log&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s"&gt;"d"&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-s1"&gt;d&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;const&lt;/span&gt; &lt;span class="pl-s1"&gt;messages&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-v"&gt;Object&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;values&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;jsonData&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;mapping&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;
          &lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;filter&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;m&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt; &lt;span class="pl-c1"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="pl-s1"&gt;m&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;message&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;
          &lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;map&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;message&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt; &lt;span class="pl-c1"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="pl-kos"&gt;{&lt;/span&gt;
            &lt;span class="pl-s1"&gt;m&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;message&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;message&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
            &lt;span class="pl-k"&gt;let&lt;/span&gt; &lt;span class="pl-s1"&gt;content&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s"&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;m&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;content&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt; &lt;span class="pl-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;m&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;content&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;text&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;content&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;m&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;content&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;text&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;else&lt;/span&gt; &lt;span class="pl-kos"&gt;{&lt;/span&gt;
                &lt;span class="pl-s1"&gt;content&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;m&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;content&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;parts&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;join&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s"&gt;"\n"&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;return&lt;/span&gt; &lt;span class="pl-kos"&gt;{&lt;/span&gt;
              &lt;span class="pl-c1"&gt;id&lt;/span&gt;: &lt;span class="pl-s1"&gt;m&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;id&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;
              &lt;span class="pl-c1"&gt;conversation_id&lt;/span&gt;: &lt;span class="pl-s1"&gt;conversationId&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;
              &lt;span class="pl-c1"&gt;author_role&lt;/span&gt;: &lt;span class="pl-s1"&gt;m&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;author&lt;/span&gt; ? &lt;span class="pl-s1"&gt;m&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;author&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;role&lt;/span&gt; : &lt;span class="pl-c1"&gt;null&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;
              &lt;span class="pl-c1"&gt;author_metadata&lt;/span&gt;: &lt;span class="pl-c1"&gt;JSON&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;stringify&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;
                &lt;span class="pl-s1"&gt;m&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;author&lt;/span&gt; ? &lt;span class="pl-s1"&gt;m&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;author&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;metadata&lt;/span&gt; : &lt;span class="pl-kos"&gt;{&lt;/span&gt;&lt;span class="pl-kos"&gt;}&lt;/span&gt;
              &lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;
              &lt;span class="pl-c1"&gt;create_time&lt;/span&gt;: &lt;span class="pl-s1"&gt;m&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;create_time&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;
              &lt;span class="pl-c1"&gt;content&lt;/span&gt;: &lt;span class="pl-s1"&gt;content&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;
              &lt;span class="pl-c1"&gt;end_turn&lt;/span&gt;: &lt;span class="pl-s1"&gt;m&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;end_turn&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;
              &lt;span class="pl-c1"&gt;weight&lt;/span&gt;: &lt;span class="pl-s1"&gt;m&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;weight&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;
              &lt;span class="pl-c1"&gt;metadata&lt;/span&gt;: &lt;span class="pl-c1"&gt;JSON&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;stringify&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;m&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;metadata&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;
              &lt;span class="pl-c1"&gt;recipient&lt;/span&gt;: &lt;span class="pl-s1"&gt;m&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;recipient&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;
            &lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
          &lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
        &lt;span class="pl-en"&gt;fetch&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;
          &lt;span class="pl-s"&gt;"https://starlette-cors-proxy-simonw-datasette.vercel.app/data/chatgpt_message/-/insert"&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;
          &lt;span class="pl-kos"&gt;{&lt;/span&gt;
            &lt;span class="pl-c1"&gt;method&lt;/span&gt;: &lt;span class="pl-s"&gt;"POST"&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;
            &lt;span class="pl-c1"&gt;headers&lt;/span&gt;: &lt;span class="pl-kos"&gt;{&lt;/span&gt;
              &lt;span class="pl-s"&gt;"Content-Type"&lt;/span&gt;: &lt;span class="pl-s"&gt;"application/json"&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 class="pl-s1"&gt;&lt;span class="pl-kos"&gt;${&lt;/span&gt;&lt;span class="pl-c1"&gt;TOKEN&lt;/span&gt;&lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;/span&gt;`&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;
            &lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;
            &lt;span class="pl-c1"&gt;mode&lt;/span&gt;: &lt;span class="pl-s"&gt;"cors"&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;
            &lt;span class="pl-c1"&gt;body&lt;/span&gt;: &lt;span class="pl-c1"&gt;JSON&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;stringify&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-kos"&gt;{&lt;/span&gt;
              &lt;span class="pl-c1"&gt;rows&lt;/span&gt;: &lt;span class="pl-s1"&gt;messages&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;
              &lt;span class="pl-c1"&gt;replace&lt;/span&gt;: &lt;span class="pl-c1"&gt;true&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;
            &lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;
          &lt;span class="pl-kos"&gt;}&lt;/span&gt;
        &lt;span class="pl-kos"&gt;)&lt;/span&gt;
          &lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;then&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;d&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt; &lt;span class="pl-c1"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="pl-s1"&gt;d&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;json&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;
          &lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;then&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;d&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt; &lt;span class="pl-c1"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="pl-smi"&gt;console&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;log&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s"&gt;"d"&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-s1"&gt;d&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
      &lt;span class="pl-kos"&gt;}&lt;/span&gt;
    &lt;span class="pl-kos"&gt;}&lt;/span&gt;

    &lt;span class="pl-c"&gt;// Return the original response&lt;/span&gt;
    &lt;span class="pl-k"&gt;return&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-kos"&gt;}&lt;/span&gt; &lt;span class="pl-k"&gt;catch&lt;/span&gt; &lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;error&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt; &lt;span class="pl-kos"&gt;{&lt;/span&gt;
    &lt;span class="pl-c"&gt;// Handle any errors that occur during the fetch&lt;/span&gt;
    &lt;span class="pl-smi"&gt;console&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;error&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s"&gt;"Error fetching and saving JSON:"&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-s1"&gt;error&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-s1"&gt;error&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
  &lt;span class="pl-kos"&gt;}&lt;/span&gt;
&lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;The fiddly bit here was writing the JavaScript that reshaped the ChatGPT JSON into the &lt;code&gt;rows: [array-of-objects]&lt;/code&gt; format needed by the &lt;a href="https://docs.datasette.io/en/1.0a2/json_api.html#the-json-write-api"&gt;Datasette JSON APIs&lt;/a&gt;. I could probably have gotten ChatGPT to help with that - but in this case I pasted the SQL schema into a comment and let GitHub Copilot auto-complete parts of the JavaScript for me as I typed it.&lt;/p&gt;
&lt;h4 id="and-it-works"&gt;And it works&lt;/h4&gt;
&lt;p&gt;Now I can paste the above block of code into the browser console on &lt;code&gt;chat.openai.com&lt;/code&gt; and any time I click on one of my older conversations in the sidebar the &lt;code&gt;fetch()&lt;/code&gt; will be intercepted and the JSON data will be saved to my Datasette Cloud instance.&lt;/p&gt;
&lt;!-- &lt;h4 id="public-demo"&gt;A public demo&lt;/h4&gt;
&lt;p&gt;I've set up a public demo exposing messages from selected conversations here:&lt;/p&gt;
&lt;p&gt;&lt;a href="https://simon.datasette.cloud/data/chatgpt_public_messages?_facet=conversation"&gt;simon.datasette.cloud/data/chatgpt_public_messages&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;The demo itself is powered by an extra table (listing the conversations that should be public) and a SQL view.&lt;/p&gt;
&lt;p&gt;I used the &lt;code&gt;datasette-write&lt;/code&gt; plugin again to create these:&lt;/p&gt;
&lt;div class="highlight highlight-source-sql"&gt;&lt;pre&gt;&lt;span class="pl-k"&gt;create&lt;/span&gt; &lt;span class="pl-k"&gt;table&lt;/span&gt; &lt;span class="pl-en"&gt;chatgpt_public&lt;/span&gt; (id &lt;span class="pl-k"&gt;text&lt;/span&gt; &lt;span class="pl-k"&gt;primary key&lt;/span&gt;);

&lt;span class="pl-k"&gt;create&lt;/span&gt; &lt;span class="pl-k"&gt;view&lt;/span&gt; &lt;span class="pl-en"&gt;chatgpt_public_messages&lt;/span&gt; &lt;span class="pl-k"&gt;as&lt;/span&gt; &lt;span class="pl-k"&gt;select&lt;/span&gt;
  &lt;span class="pl-c1"&gt;chatgpt_message&lt;/span&gt;.&lt;span class="pl-c1"&gt;id&lt;/span&gt;,
  &lt;span class="pl-c1"&gt;chatgpt_conversation&lt;/span&gt;.&lt;span class="pl-c1"&gt;title&lt;/span&gt; &lt;span class="pl-k"&gt;||&lt;/span&gt; &lt;span class="pl-k"&gt;char&lt;/span&gt;(&lt;span class="pl-c1"&gt;10&lt;/span&gt;) &lt;span class="pl-k"&gt;||&lt;/span&gt; &lt;span class="pl-c1"&gt;chatgpt_conversation&lt;/span&gt;.&lt;span class="pl-c1"&gt;id&lt;/span&gt; &lt;span class="pl-k"&gt;as&lt;/span&gt; conversation,
  &lt;span class="pl-c1"&gt;chatgpt_message&lt;/span&gt;.&lt;span class="pl-c1"&gt;author_role&lt;/span&gt;,
  &lt;span class="pl-c1"&gt;chatgpt_message&lt;/span&gt;.&lt;span class="pl-c1"&gt;content&lt;/span&gt;,
  datetime(&lt;span class="pl-c1"&gt;chatgpt_message&lt;/span&gt;.&lt;span class="pl-c1"&gt;create_time&lt;/span&gt;, &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;unixepoch&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;) &lt;span class="pl-k"&gt;as&lt;/span&gt; create_time
&lt;span class="pl-k"&gt;from&lt;/span&gt;
  chatgpt_message &lt;span class="pl-k"&gt;join&lt;/span&gt; chatgpt_conversation &lt;span class="pl-k"&gt;on&lt;/span&gt; conversation_id &lt;span class="pl-k"&gt;=&lt;/span&gt; &lt;span class="pl-c1"&gt;chatgpt_conversation&lt;/span&gt;.&lt;span class="pl-c1"&gt;id&lt;/span&gt;
&lt;span class="pl-k"&gt;where&lt;/span&gt;
  &lt;span class="pl-c1"&gt;chatgpt_message&lt;/span&gt;.&lt;span class="pl-c1"&gt;create_time&lt;/span&gt; &lt;span class="pl-k"&gt;is not null&lt;/span&gt;
  &lt;span class="pl-k"&gt;and&lt;/span&gt; conversation_id &lt;span class="pl-k"&gt;in&lt;/span&gt; (&lt;span class="pl-k"&gt;select&lt;/span&gt; id &lt;span class="pl-k"&gt;from&lt;/span&gt; chatgpt_public)
&lt;span class="pl-k"&gt;order by&lt;/span&gt;
  &lt;span class="pl-c1"&gt;chatgpt_message&lt;/span&gt;.&lt;span class="pl-c1"&gt;create_time&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Then I set the &lt;code&gt;chatgpt_public_messages&lt;/code&gt; view to be public (using &lt;a href="https://datasette.io/plugins/datasette-public"&gt;datasette-public&lt;/a&gt;).&lt;/p&gt;
&lt;p&gt;Now I can insert conversation IDs into that &lt;code&gt;chatgpt_public&lt;/code&gt; table to expose their messages in the public view.&lt;/p&gt;
&lt;p&gt;This is the first time I've used a SQL view like this to selectively publish data from a private larger table, and I think it's a really neat pattern. I'd like to make it easier to do without writing custom SQL though!&lt;/p&gt; --&gt;
&lt;h4 id="more-than-just-this"&gt;It's a lot more than just this project&lt;/h4&gt;
&lt;p&gt;This ChatGPT archiving problem is just one example from the past few months of things I've built that I wouldn't have tackled without AI-assistance.&lt;/p&gt;
&lt;p&gt;It took me longer to write this up than it did to implement the entire project from start to finish!&lt;/p&gt;
&lt;p&gt;When evaluating if a new technology is worth learning and adopting, I have two criteria:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Does this let me build things that would have been impossible to build without it?&lt;/li&gt;
&lt;li&gt;Can this reduce the effort required for some projects such that they tip over from "not worth it" to "worth it" and I end up building them?&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Large language models like GPT3/4/LLaMA/Claude etc clearly meet both of those criteria - and their impact on point two keeps on getting stronger for me.&lt;/p&gt;
&lt;h4 id="some-more-examples"&gt;Some more examples&lt;/h4&gt;
&lt;p&gt;Here are a few more examples of projects I've worked on recently that wouldn't have happened without at least some level of AI assistance:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;I used ChatGPT to &lt;a href="https://simonwillison.net/2023/Mar/24/datasette-chatgpt-plugin/"&gt;generate me the OpenAI schema&lt;/a&gt; I needed to build the &lt;a href="https://datasette.io/plugins/datasette-chatgpt-plugin"&gt;datasette-chatgpt-plugin&lt;/a&gt; plugin, allowing human language questions in ChatGPT to be answered by SQL queries executed against Datasette.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://til.simonwillison.net/gpt3/chatgpt-applescript"&gt;Using ChatGPT to write AppleScript&lt;/a&gt; describes how I used ChatGPT to finally figure out enough AppleScript to liberate my notes data, resulting in building &lt;a href="https://datasette.io/tools/apple-notes-to-sqlite"&gt;apple-notes-to-sqlite&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;datasette-paste-table&lt;/code&gt; isn't in a usable state yet, but I built the first interactive prototype for that &lt;a href="https://github.com/simonw/datasette-paste-table/issues/1"&gt;using ChatGPT&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://til.simonwillison.net/jq/git-log-json"&gt;Convert git log output to JSON using jq&lt;/a&gt; is something I figured out using ChatGPT - &lt;a href="https://gist.github.com/simonw/c3b486fa90d7c32a0e8dfb47e151090a"&gt;transcript here&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://simonwillison.net/2022/Dec/5/rust-chatgpt-copilot/"&gt;Learning Rust with ChatGPT, Copilot and Advent of Code&lt;/a&gt; describes one of my earlier efforts to use ChatGPT to help learn a completely new (to me) programming language.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://til.simonwillison.net/googlecloud/video-frame-ocr"&gt;Reading thermometer temperatures over time from a video&lt;/a&gt; describes a project I built using ffmpg and Google Cloud Vision.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://til.simonwillison.net/datasette/row-selection-prototype"&gt;Interactive row selection prototype with Datasette&lt;/a&gt; explains a more complex HTML and JavaScript UI prototype I worked on.&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/ai"&gt;ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/generative-ai"&gt;generative-ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/chatgpt"&gt;chatgpt&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/github-copilot"&gt;github-copilot&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/applescript"&gt;applescript&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/starlette"&gt;starlette&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="projects"/><category term="ai"/><category term="generative-ai"/><category term="chatgpt"/><category term="github-copilot"/><category term="applescript"/><category term="llms"/><category term="ai-assisted-programming"/><category term="starlette"/></entry><entry><title>asgi-gzip 0.2</title><link href="https://simonwillison.net/2022/Oct/13/asgi-gzip/#atom-tag" rel="alternate"/><published>2022-10-13T23:47:49+00:00</published><updated>2022-10-13T23:47:49+00:00</updated><id>https://simonwillison.net/2022/Oct/13/asgi-gzip/#atom-tag</id><summary type="html">
    &lt;p&gt;&lt;strong&gt;Release:&lt;/strong&gt; &lt;a href="https://github.com/simonw/asgi-gzip/releases/tag/0.2"&gt;asgi-gzip 0.2&lt;/a&gt;&lt;/p&gt;
    
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/starlette"&gt;starlette&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="starlette"/></entry><entry><title>Automatically opening issues when tracked file content changes</title><link href="https://simonwillison.net/2022/Apr/28/issue-on-changes/#atom-tag" rel="alternate"/><published>2022-04-28T17:18:14+00:00</published><updated>2022-04-28T17:18:14+00:00</updated><id>https://simonwillison.net/2022/Apr/28/issue-on-changes/#atom-tag</id><summary type="html">
    &lt;p&gt;I figured out a GitHub Actions pattern to keep track of a file published somewhere on the internet and automatically open a new repository issue any time the contents of that file changes.&lt;/p&gt;
&lt;h4&gt;Extracting GZipMiddleware from Starlette&lt;/h4&gt;
&lt;p&gt;Here's why I needed to solve this problem.&lt;/p&gt;
&lt;p&gt;I want to add gzip support to my &lt;a href="https://datasette.io/"&gt;Datasette&lt;/a&gt; open source project. Datasette builds on the Python &lt;a href="https://asgi.readthedocs.io/"&gt;ASGI&lt;/a&gt; standard, and &lt;a href="https://www.starlette.io/"&gt;Starlette&lt;/a&gt; provides an extremely well tested, robust &lt;a href="https://www.starlette.io/middleware/#gzipmiddleware"&gt;GZipMiddleware class&lt;/a&gt; that adds gzip support to any ASGI application. As with everything else in Starlette, it's &lt;em&gt;really&lt;/em&gt; good code.&lt;/p&gt;
&lt;p&gt;The problem is, I don't want to add the whole of Starlette as a dependency. I'm trying to keep Datasette's core as small as possible, so I'm very careful about new dependencies. Starlette itself is actually very light (and only has a tiny number of dependencies of its own) but I still don't want the whole thing just for that one class.&lt;/p&gt;
&lt;p&gt;So I decided to extract the &lt;code&gt;GZipMiddleware&lt;/code&gt; class into a separate Python package, under the same BSD license as Starlette itself.&lt;/p&gt;
&lt;p&gt;The result is my new &lt;a href="https://pypi.org/project/asgi-gzip/"&gt;asgi-gzip&lt;/a&gt; package, now available on PyPI.&lt;/p&gt;
&lt;h4&gt;What if Starlette fixes a bug?&lt;/h4&gt;
&lt;p&gt;The problem with extracting code like this is that Starlette is a very effectively maintained package. What if they make improvements or fix bugs in the &lt;code&gt;GZipMiddleware&lt;/code&gt; class? How can I make sure to apply those same fixes to my extracted copy?&lt;/p&gt;
&lt;p&gt;As I thought about this challenge, I realized I had most of the solution already.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://simonwillison.net/2020/Oct/9/git-scraping/"&gt;Git scraping&lt;/a&gt;&lt;/strong&gt; is the name I've given to the trick of running a periodic scraper that writes to a git repository in order to track changes to data over time.&lt;/p&gt;
&lt;p&gt;It may seem redundant to do this against a file that already &lt;a href="https://github.com/encode/starlette/commits/master/starlette/middleware/gzip.py"&gt;lives in version control&lt;/a&gt; elsewhere - but in addition to tracking changes, Git scraping can offfer a cheap and easy way to add automation that triggers when a change is detected.&lt;/p&gt;
&lt;p&gt;I need an actionable alert any time the Starlette code changes so I can review the change and apply a fix to my own library, if necessary.&lt;/p&gt;
&lt;p&gt;Since I already run all of my projects out of GitHub issues, automatically opening an issue against the &lt;a href="https://github.com/simonw/asgi-gzip"&gt;asgi-gzip repository&lt;/a&gt; would be ideal.&lt;/p&gt;
&lt;p&gt;My &lt;a href="https://github.com/simonw/asgi-gzip/blob/0.1/.github/workflows/track.yml"&gt;track.yml workflow&lt;/a&gt; does exactly that: it implements the Git scraping pattern against the &lt;a href="https://github.com/encode/starlette/blob/master/starlette/middleware/gzip.py"&gt;gzip.py module&lt;/a&gt; in Starlette, and files an issue any time it detects changes to that file.&lt;/p&gt;
&lt;p&gt;Starlette haven't made any changes to that file since I started tracking it, so I created &lt;a href="https://github.com/simonw/issue-when-changed"&gt;a test repo&lt;/a&gt; to try this out.&lt;/p&gt;
&lt;p&gt;Here's &lt;a href="https://github.com/simonw/issue-when-changed/issues/3"&gt;one of the example issues&lt;/a&gt;. I decided to include the visual diff in the issue description and have a link to it from the underlying commit as well.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2022/issue-when-changed.jpg" alt="Screenshot of an open issue page. The issues is titled &amp;quot;gzip.py was updated&amp;quot; and contains a visual diff showing the change to a file. A commit that references the issue is listed too." style="max-width:100%;" /&gt;&lt;/p&gt;
&lt;h4&gt;How it works&lt;/h4&gt;
&lt;p&gt;The implementation is contained entirely in this &lt;a href="https://github.com/simonw/asgi-gzip/blob/0.1/.github/workflows/track.yml"&gt;track.yml workflow&lt;/a&gt;. I designed this to be contained as a single file to make it easy to copy and paste it to adapt it for other projects.&lt;/p&gt;
&lt;p&gt;It uses &lt;a href="https://github.com/actions/github-script"&gt;actions/github-script&lt;/a&gt;, which makes it easy to do things like file new issues using JavaScript.&lt;/p&gt;
&lt;p&gt;Here's a heavily annotated copy:&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;Track the Starlette version of this&lt;/span&gt;

&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; Run on repo pushes, and if a user clicks the "run this action" button,&lt;/span&gt;
&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; and on a schedule at 5:21am UTC every day&lt;/span&gt;
&lt;span class="pl-ent"&gt;on&lt;/span&gt;:
  &lt;span class="pl-ent"&gt;push&lt;/span&gt;:
  &lt;span class="pl-ent"&gt;workflow_dispatch&lt;/span&gt;:
  &lt;span class="pl-ent"&gt;schedule&lt;/span&gt;:
  - &lt;span class="pl-ent"&gt;cron&lt;/span&gt;:  &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;21 5 * * *&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;

&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; Without this block I got this error when the action ran:&lt;/span&gt;
&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; HttpError: Resource not accessible by integration&lt;/span&gt;
&lt;span class="pl-ent"&gt;permissions&lt;/span&gt;:
  &lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; Allow the action to create issues&lt;/span&gt;
  &lt;span class="pl-ent"&gt;issues&lt;/span&gt;: &lt;span class="pl-s"&gt;write&lt;/span&gt;
  &lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; Allow the action to commit back to the repository&lt;/span&gt;
  &lt;span class="pl-ent"&gt;contents&lt;/span&gt;: &lt;span class="pl-s"&gt;write&lt;/span&gt;

&lt;span class="pl-ent"&gt;jobs&lt;/span&gt;:
  &lt;span class="pl-ent"&gt;check&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;uses&lt;/span&gt;: &lt;span class="pl-s"&gt;actions/checkout@v2&lt;/span&gt;
    - &lt;span class="pl-ent"&gt;uses&lt;/span&gt;: &lt;span class="pl-s"&gt;actions/github-script@v6&lt;/span&gt;
      &lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; Using env: here to demonstrate how an action like this can&lt;/span&gt;
      &lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; be adjusted to take dynamic inputs&lt;/span&gt;
      &lt;span class="pl-ent"&gt;env&lt;/span&gt;:
        &lt;span class="pl-ent"&gt;URL&lt;/span&gt;: &lt;span class="pl-s"&gt;https://raw.githubusercontent.com/encode/starlette/master/starlette/middleware/gzip.py&lt;/span&gt;
        &lt;span class="pl-ent"&gt;FILE_NAME&lt;/span&gt;: &lt;span class="pl-s"&gt;tracking/gzip.py&lt;/span&gt;
      &lt;span class="pl-ent"&gt;with&lt;/span&gt;:
        &lt;span class="pl-ent"&gt;script&lt;/span&gt;: &lt;span class="pl-s"&gt;|&lt;/span&gt;
&lt;span class="pl-s"&gt;          const { URL, FILE_NAME } = process.env;&lt;/span&gt;
&lt;span class="pl-s"&gt;          // promisify pattern for getting an await version of child_process.exec&lt;/span&gt;
&lt;span class="pl-s"&gt;          const util = require("util");&lt;/span&gt;
&lt;span class="pl-s"&gt;          // Used exec_ here because 'exec' variable name is already used:&lt;/span&gt;
&lt;span class="pl-s"&gt;          const exec_ = util.promisify(require("child_process").exec);&lt;/span&gt;
&lt;span class="pl-s"&gt;          // Use curl to download the file&lt;/span&gt;
&lt;span class="pl-s"&gt;          await exec_(`curl -o ${FILE_NAME} ${URL}`);&lt;/span&gt;
&lt;span class="pl-s"&gt;          // Use 'git diff' to detect if the file has changed since last time&lt;/span&gt;
&lt;span class="pl-s"&gt;          const { stdout } = await exec_(`git diff ${FILE_NAME}`);&lt;/span&gt;
&lt;span class="pl-s"&gt;          if (stdout) {&lt;/span&gt;
&lt;span class="pl-s"&gt;            // There was a diff to that file&lt;/span&gt;
&lt;span class="pl-s"&gt;            const title = `${FILE_NAME} was updated`;&lt;/span&gt;
&lt;span class="pl-s"&gt;            const body =&lt;/span&gt;
&lt;span class="pl-s"&gt;              `${URL} changed:` +&lt;/span&gt;
&lt;span class="pl-s"&gt;              "\n\n```diff\n" +&lt;/span&gt;
&lt;span class="pl-s"&gt;              stdout +&lt;/span&gt;
&lt;span class="pl-s"&gt;              "\n```\n\n" +&lt;/span&gt;
&lt;span class="pl-s"&gt;              "Close this issue once those changes have been integrated here";&lt;/span&gt;
&lt;span class="pl-s"&gt;            const issue = await github.rest.issues.create({&lt;/span&gt;
&lt;span class="pl-s"&gt;              owner: context.repo.owner,&lt;/span&gt;
&lt;span class="pl-s"&gt;              repo: context.repo.repo,&lt;/span&gt;
&lt;span class="pl-s"&gt;              title: title,&lt;/span&gt;
&lt;span class="pl-s"&gt;              body: body,&lt;/span&gt;
&lt;span class="pl-s"&gt;            });&lt;/span&gt;
&lt;span class="pl-s"&gt;            const issueNumber = issue.data.number;&lt;/span&gt;
&lt;span class="pl-s"&gt;            // Now commit and reference that issue number, so the commit shows up&lt;/span&gt;
&lt;span class="pl-s"&gt;            // listed at the bottom of the issue page&lt;/span&gt;
&lt;span class="pl-s"&gt;            const commitMessage = `${FILE_NAME} updated, refs #${issueNumber}`;&lt;/span&gt;
&lt;span class="pl-s"&gt;            // https://til.simonwillison.net/github-actions/commit-if-file-changed&lt;/span&gt;
&lt;span class="pl-s"&gt;            await exec_(`git config user.name "Automated"`);&lt;/span&gt;
&lt;span class="pl-s"&gt;            await exec_(`git config user.email "actions@users.noreply.github.com"`);&lt;/span&gt;
&lt;span class="pl-s"&gt;            await exec_(`git add -A`);&lt;/span&gt;
&lt;span class="pl-s"&gt;            await exec_(`git commit -m "${commitMessage}" || exit 0`);&lt;/span&gt;
&lt;span class="pl-s"&gt;            await exec_(`git pull --rebase`);&lt;/span&gt;
&lt;span class="pl-s"&gt;            await exec_(`git push`);&lt;/span&gt;
&lt;span class="pl-s"&gt;          }&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;In the &lt;a href="https://github.com/simonw/asgi-gzip"&gt;asgi-gzip&lt;/a&gt; repository I keep the fetched &lt;code&gt;gzip.py&lt;/code&gt; file in a &lt;code&gt;tracking/&lt;/code&gt; directory. This directory isn't included in the Python package that gets uploaded to PyPI - it's there only so that my code can track changes to it over time.&lt;/p&gt;
&lt;h4&gt;More interesting applications&lt;/h4&gt;
&lt;p&gt;I built this to solve my "tell me when Starlette update their &lt;code&gt;gzip.py&lt;/code&gt; file" problem, but clearly this pattern has much more interesting uses.&lt;/p&gt;
&lt;p&gt;You could point this at any web page to get a new GitHub issue opened when that page content changes. Subscribe to notifications for that repository and you get a robust , shared mechanism for alerts - plus an issue system where you can post additional comments and close the issue once someone has reviewed the change.&lt;/p&gt;
&lt;p&gt;There's a lot of potential here for solving all kinds of interesting problems. And it doesn't cost anything either: GitHub Actions (somehow) remains completely free for public repositories!&lt;/p&gt;
&lt;h4&gt;Update: October 13th 2022&lt;/h4&gt;
&lt;p&gt;Almost six months after writing about this... it triggered for the first time!&lt;/p&gt;
&lt;p&gt;Here's the issue that the script opened: &lt;a href="https://github.com/simonw/asgi-gzip/issues/4"&gt;#4: tracking/gzip.py was updated&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;I applied the improvement (Marcelo Trylesinski and Kai Klingenberg updated Starlette's code to avoid gzipping if the response already had a Content-Encoding header) and released &lt;a href="https://github.com/simonw/asgi-gzip/releases/tag/0.2"&gt;version 0.2&lt;/a&gt; of the package.&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/gzip"&gt;gzip&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/datasette"&gt;datasette&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/asgi"&gt;asgi&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/git-scraping"&gt;git-scraping&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/github-issues"&gt;github-issues&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/starlette"&gt;starlette&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="github"/><category term="gzip"/><category term="projects"/><category term="python"/><category term="datasette"/><category term="asgi"/><category term="github-actions"/><category term="git-scraping"/><category term="github-issues"/><category term="starlette"/></entry><entry><title>Exploring the SameSite cookie attribute for preventing CSRF</title><link href="https://simonwillison.net/2021/Aug/3/samesite/#atom-tag" rel="alternate"/><published>2021-08-03T21:09:02+00:00</published><updated>2021-08-03T21:09:02+00:00</updated><id>https://simonwillison.net/2021/Aug/3/samesite/#atom-tag</id><summary type="html">
    &lt;p&gt;In reading Yan Zhu's excellent write-up of the &lt;a href="https://blog.azuki.vip/csrf/"&gt;JSON CSRF vulnerability&lt;/a&gt; she found in OkCupid one thing puzzled me: I was under the impression that browsers these days default to treating cookies as &lt;code&gt;SameSite=Lax&lt;/code&gt;, so I would expect attacks like the one Yan described not to work in modern browsers.&lt;/p&gt;
&lt;p&gt;This lead me down a rabbit hole of exploring how SameSite actually works, including building &lt;a href="https://samesite-lax-demo.vercel.app/"&gt;an interactive SameSite cookie exploration tool&lt;/a&gt; along the way. Here's what I learned.&lt;/p&gt;
&lt;h4 id="background-csrf"&gt;Background: Cross-Site Request Forgery&lt;/h4&gt;
&lt;p&gt;I've been tracking CSRF (Cross-Site Request Forgery) &lt;a href="https://simonwillison.net/tags/csrf/?page=2"&gt;on this blog&lt;/a&gt; since 2005(!)&lt;/p&gt;
&lt;p&gt;A quick review: let's say you have a page in your application that allows a user to delete their account, at &lt;code&gt;https://www.example.com/delete-my-account&lt;/code&gt;. The user has to be signed in with a cookie in order to activate that feature.&lt;/p&gt;
&lt;p&gt;If you created that page to respond to &lt;code&gt;GET&lt;/code&gt; requests, I as an evil person could create a page at &lt;code&gt;https://www.evil.com/force-you-to-delete-your-account&lt;/code&gt; that does this:&lt;/p&gt;
&lt;div class="highlight highlight-text-html-basic"&gt;&lt;pre&gt;&lt;span class="pl-kos"&gt;&amp;lt;&lt;/span&gt;&lt;span class="pl-ent"&gt;img&lt;/span&gt; &lt;span class="pl-c1"&gt;src&lt;/span&gt;="&lt;span class="pl-s"&gt;https://www.example.com/delete-my-account&lt;/span&gt;"&lt;span class="pl-kos"&gt;&amp;gt;&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;If I can get you to visit my page, I can force you to delete your account!&lt;/p&gt;
&lt;p&gt;But you're smarter than that, and you know that GET requests should be idempotent. You implement your endpoint to require a POST request instead.&lt;/p&gt;
&lt;p&gt;Turns out I can still force-delete accounts, if I can trick a user into visiting a page with the following evil HTML on it:&lt;/p&gt;
&lt;div class="highlight highlight-text-html-basic"&gt;&lt;pre&gt;&lt;span class="pl-kos"&gt;&amp;lt;&lt;/span&gt;&lt;span class="pl-ent"&gt;form&lt;/span&gt; &lt;span class="pl-c1"&gt;action&lt;/span&gt;="&lt;span class="pl-s"&gt;https://www.example.com/delete-my-account&lt;/span&gt;" &lt;span class="pl-c1"&gt;method&lt;/span&gt;="&lt;span class="pl-s"&gt;POST&lt;/span&gt;"&lt;span class="pl-kos"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="pl-kos"&gt;&amp;lt;&lt;/span&gt;&lt;span class="pl-ent"&gt;input&lt;/span&gt; &lt;span class="pl-c1"&gt;type&lt;/span&gt;="&lt;span class="pl-s"&gt;submit&lt;/span&gt;" &lt;span class="pl-c1"&gt;value&lt;/span&gt;="&lt;span class="pl-s"&gt;Delete my account&lt;/span&gt;"&lt;span class="pl-kos"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="pl-kos"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="pl-ent"&gt;form&lt;/span&gt;&lt;span class="pl-kos"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="pl-kos"&gt;&amp;lt;&lt;/span&gt;&lt;span class="pl-ent"&gt;script&lt;/span&gt;&lt;span class="pl-kos"&gt;&amp;gt;&lt;/span&gt;&lt;span class="pl-smi"&gt;document&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;forms&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-en"&gt;submit&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="pl-ent"&gt;script&lt;/span&gt;&lt;span class="pl-kos"&gt;&amp;gt;&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;The form submits with JavaScript the instant they load the page!&lt;/p&gt;
&lt;p&gt;CSRF is an extremely common and nasty vulnerability - especially since it's a hole by default: if you don't know what CSRF is, you likely have it in your application.&lt;/p&gt;
&lt;p&gt;Traditionally the solution has been to use CSRF tokens - hidden form fields which "prove" that the user came from a form on your own site, and not a form hosted somewhere else. OWASP call this the &lt;a href="https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#double-submit-cookie"&gt;Double Submit Cookie&lt;/a&gt; pattern.&lt;/p&gt;
&lt;p&gt;Web frameworks like Django implement &lt;a href="https://docs.djangoproject.com/en/3.2/ref/csrf/"&gt;CSRF protection&lt;/a&gt; for you. I built &lt;a href="https://github.com/simonw/asgi-csrf"&gt;asgi-csrf&lt;/a&gt; to help add CSRF token protection to ASGI applications.&lt;/p&gt;
&lt;h4 id="samesite-cookie-attribute"&gt;Enter the SameSite cookie attribute&lt;/h4&gt;
&lt;p&gt;Clearly it would be better if we didn't have to worry about CSRF at all.&lt;/p&gt;
&lt;p&gt;As far as I can tell, work on specifying the &lt;code&gt;SameSite&lt;/code&gt; cookie attribute started &lt;a href="https://github.com/httpwg/http-extensions/commit/aa0722c12ccb367b8f4498e982616064d105a006#diff-70cc0c0600a934d002ea91a4a36d5eb0b7d5edebcce5a40c9a811391cc0fecf6"&gt;in June 2016&lt;/a&gt;. The idea was to add an additional attribute to cookies that specifies the policy for if they should be included in requests made to a domain from pages hosted on another domain.&lt;/p&gt;
&lt;p&gt;Today, all modern browsers support SameSite. MDN &lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie/SameSite"&gt;has SameSite documentation&lt;/a&gt;, but a summary is:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;SameSite=None&lt;/code&gt; - the cookie is sent in "all contexts" - more-or-less how things used to work before SameSite was invented. &lt;strong&gt;Update:&lt;/strong&gt; One major edge-case here is that Safari apparently ignores &lt;code&gt;None&lt;/code&gt; if the "Prevent cross-site tracking" privacy preference is turned on - and since that is on by default, this means that &lt;code&gt;SameSite=None&lt;/code&gt; is effectively useless if you care about Safari or Mobile Safari users.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;SameSite=Strict&lt;/code&gt; - the cookie is only sent for requests that originate on the same domain. Even arriving on the site from an off-site link will not see the cookie, unless you subsequently refresh the page or navigate within the site.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;SameSite=Lax&lt;/code&gt; - cookie is sent if you navigate to the site through following a link from another domain but &lt;em&gt;not&lt;/em&gt; if you submit a form. This is generally what you want to protect against CSRF attacks!&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The attribute is specified by the server in a &lt;code&gt;set-cookie&lt;/code&gt; header that looks like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;set-cookie: lax-demo=3473; Path=/; SameSite=lax
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Why not habitually use &lt;code&gt;SameSite=Strict&lt;/code&gt;? Because then if someone follows a link to your site their first request will be treated as if they are not signed in at all. That's bad!&lt;/p&gt;
&lt;p&gt;So explicitly setting a cookie with &lt;code&gt;SameSite=Lax&lt;/code&gt; should be enough to protect your application from CSRF vulnerabilities... provided your users have a browser that supports it.&lt;/p&gt;
&lt;p&gt;(Can I Use reports &lt;a href="https://caniuse.com/same-site-cookie-attribute"&gt;93.95% global support&lt;/a&gt; for the attribute - not quite high enough for me to stop habitually using CSRF tokens, but we're getting there.)&lt;/p&gt;
&lt;h4 id="samesite-missing"&gt;What if the SameSite attribute is missing?&lt;/h4&gt;
&lt;p&gt;Here's where things get interesting. If a cookie is set without a SameSite attribute at all, how should the browser treat it?&lt;/p&gt;
&lt;p&gt;Over the past year, all of the major browsers have been changing their default behaviour. The goal is for a missing SameSite attribute to be treated as if it was &lt;code&gt;SameSite=Lax&lt;/code&gt; - providing CSRF protection by default.&lt;/p&gt;
&lt;p&gt;I have found it infuriatingly difficult to track down if and when this change has been made:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Chrome/Chromium offer &lt;a href="https://www.chromium.org/updates/same-site"&gt;the best documentation&lt;/a&gt; - they claim to have ramped up the new default to 100% of users in August 2020. WebViews in Android still have the old default behaviour, which is scheduled to be fixed in Android 12 (&lt;a href="https://en.wikipedia.org/wiki/Android_12"&gt;not yet released&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;Firefox have a &lt;a href="https://hacks.mozilla.org/2020/08/changes-to-samesite-cookie-behavior/"&gt;blog entry from August 2020&lt;/a&gt; which says "Starting with Firefox 79 (June 2020), we rolled it out to 50% of the Firefox Beta user base" - but I've not been able to find any subsequent updates. &lt;strong&gt;Update 26th August 2024:&lt;/strong&gt; It &lt;a href="https://simonwillison.net/2024/Aug/26/frederik-braun/"&gt;turns out&lt;/a&gt; Firefox didn't ship this after all, going with their own &lt;a href="https://blog.mozilla.org/en/mozilla/firefox-rolls-out-total-cookie-protection-by-default-to-all-users-worldwide/"&gt;Total Cookie Protection&lt;/a&gt; solution instead, which rolled out in April 2023.&lt;/li&gt;
&lt;li&gt;I have no idea at all what's going on with Safari!&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I started &lt;a href="https://twitter.com/simonw/status/1422366158171238400"&gt;a Twitter thread&lt;/a&gt; to try and collect more information, so please reply there if you know what's going on in more detail.&lt;/p&gt;
&lt;h4 id="chrome-2-minute-twist"&gt;The Chrome 2-minute twist&lt;/h4&gt;
&lt;p&gt;Assuming all of the above, the mystery remained: how did Yan's exploit fail to be prevented by browsers?&lt;/p&gt;
&lt;p&gt;After some back-and-forth about this on Twitter &lt;a href="https://twitter.com/bcrypt/status/1422370774896177154"&gt;Yan proposed&lt;/a&gt; that the answer may be this detail, tucked away on the &lt;a href="https://www.chromestatus.com/feature/5088147346030592"&gt;Chrome Platform Status page for Feature: Cookies default to SameSite=Lax&lt;/a&gt;.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Note: Chrome will make an exception for cookies set without a SameSite attribute less than 2 minutes ago. Such cookies will also be sent with non-idempotent (e.g. POST) top-level cross-site requests despite normal SameSite=Lax cookies requiring top-level cross-site requests to have a safe (e.g. GET) HTTP method. Support for this intervention ("Lax + POST") will be removed in the future.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;It looks like OkCupid were setting their authentication cookie without a &lt;code&gt;SameSite&lt;/code&gt; attribute... which opened them up to a form-based CSRF attack but only for the 120 seconds following the cookie being set!&lt;/p&gt;
&lt;h4 id="samesite-explore-tool"&gt;Building a tool to explore SameSite browser behaviour&lt;/h4&gt;
&lt;p&gt;I was finding this all very confusing, so I built a tool.&lt;/p&gt;
&lt;p&gt;&lt;img alt="A screenshot showing the two pages from the demo side-by-side" src="https://static.simonwillison.net/static/2021/samesite-tool.png" style="max-width:100%;" /&gt;&lt;/p&gt;
&lt;p&gt;The code lives in &lt;a href="https://github.com/simonw/samesite-lax-demo"&gt;simonw/samesite-lax-demo&lt;/a&gt; on GitHub, but the tool itself has two sides:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A server-side Python (&lt;a href="https://www.starlette.io/"&gt;Starlette&lt;/a&gt;) web application for setting cookies with different &lt;code&gt;SameSite&lt;/code&gt; attributes. This is hosted on Vercel at &lt;a href="https://samesite-lax-demo.vercel.app/"&gt;https://samesite-lax-demo.vercel.app/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;An HTML page on a different domain that links to that cookied site, provides a POST form targetting it, embeds an image from it and executes some &lt;code&gt;fetch()&lt;/code&gt; requests against it. This is at &lt;a href="https://simonw.github.io/samesite-lax-demo/"&gt;https://simonw.github.io/samesite-lax-demo/&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Hosting on two separate domains is critical for the tool to show what is going on. I chose Vercel and GitHub Pages because they are both trivial to set up to continuously deploy changes from a GitHub repository.&lt;/p&gt;
&lt;p&gt;Using the tool in different browsers helps show exactly what is going on with regards to cross-domain cookies.&lt;/p&gt;
&lt;p&gt;A few of the things I observed using the tool:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;SameSite=Strict&lt;/code&gt; works as you would expect. It's particularly interesting to follow the regular &lt;code&gt;&amp;lt;a href=...&amp;gt;&lt;/code&gt; link from the static site to the application and see how the strict cookie is NOT visible upon arrival - but becomes visible when you refresh that page.&lt;/li&gt;
&lt;li&gt;I included a dynamically generated SVG in a &lt;code&gt;&amp;lt;img src="/cookies.svg"&amp;gt;&lt;/code&gt; image tag, which shows the cookies (using SVG &lt;code&gt;&amp;lt;text&amp;gt;&lt;/code&gt;) that are visible to the request. That image shows all four types of cookie when embedded on the Vercel domain, but when embedded on the GitHub pages domain it differs wildly:
&lt;ul&gt;
&lt;li&gt;Firefox 89 shows both the &lt;code&gt;SameSite=None&lt;/code&gt; and the missing SameSite cookies&lt;/li&gt;
&lt;li&gt;Chrome 92 shows just the &lt;code&gt;SameSite=None&lt;/code&gt; cookie&lt;/li&gt;
&lt;li&gt;Safari 14.0 shows no cookies at all!&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Chrome won't let you set a &lt;code&gt;SameSite=None&lt;/code&gt; cookie without including the &lt;code&gt;Secure&lt;/code&gt; attribute.&lt;/li&gt;
&lt;li&gt;I also added some JavaScript that makes a cross-domain &lt;code&gt;fetch(..., {credentials: "include"})&lt;/code&gt; call against a &lt;code&gt;/cookies.json&lt;/code&gt; endpoint. This didn't send any cookies at all until I added server-side headers &lt;code&gt;access-control-allow-origin: https://simonw.github.io&lt;/code&gt; and &lt;code&gt;access-control-allow-credentials: true&lt;/code&gt;. Having done that, I got the same results across the three browsers as for the &lt;code&gt;&amp;lt;img&lt;/code&gt; test described above.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Safari ignoring &lt;code&gt;SameSite=None&lt;/code&gt; looked like it was this bug: &lt;a href="https://bugs.webkit.org/show_bug.cgi?id=198181"&gt;Cookies with SameSite=None or SameSite=invalid treated as Strict&lt;/a&gt; - it's marked as fixed but it's not clear to me if the fix has been released yet - I still saw that behaviour on my macOS 10.15.6 laptop or my iOS 14.7.1 iPhone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Update:&lt;/strong&gt;  	
&lt;a href="https://news.ycombinator.com/item?id=28092943"&gt;krinchan on Hacker News&lt;/a&gt; has an answer here:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;The Safari "bug" is a new setting that's turned on by default: "Prevent cross-site tracking". It treats all cookies as SameSite=Lax, even cookies with SameSite=None.&lt;/p&gt;&lt;/blockquote&gt;
&lt;p&gt;&lt;a href="https://webkit.org/blog/10218/full-third-party-cookie-blocking-and-more/"&gt;Full Third-Party Cookie Blocking and More&lt;/a&gt; on the WebKit blog has more about this.&lt;/p&gt;

&lt;p&gt;Most excitingly, I was able to replicate the Chrome two minute window bug using the tool! Each cookie has its value set to the timestamp when it was created, and I added code to display how many seconds ago the cookie was set. Here's an animation showing how Chrome on a form submission navigation can see the cookie that was set with &lt;code&gt;SameSite&lt;/code&gt; missing at 114 seconds old, but that cookie is no longer visible once it passes 120 seconds.&lt;/p&gt;
&lt;p&gt;&lt;img alt="Animated demo of the tool in Chrome" src="https://static.simonwillison.net/static/2021/chrome-samesite-missing-loop.gif" style="max-width:100%;" /&gt;&lt;/p&gt;
&lt;h4 id="consider-subdomains"&gt;Consider your subdomains&lt;/h4&gt;
&lt;p&gt;One last note about CSRF that you should consider:  &lt;code&gt;SameSite=Lax&lt;/code&gt; still allows form submissions from  subdomains of your primary domain to carry their cookies.&lt;/p&gt;
&lt;p&gt;This means that if you have a XSS vulnerability on one of your subdomains the security of your primary domain will be compromised.&lt;/p&gt;
&lt;p&gt;Since it's common for subdomains to host other applications that may have their own security concerns, ditching CSRF tokens for Lax cookies may not be a wise step!&lt;/p&gt;
&lt;h4 id="login-csrf-samesite-lax"&gt;Login CSRF and SameSite=Lax&lt;/h4&gt;
&lt;p&gt;Login CSRF is an interesting variety of CSRF with slightly different rules.&lt;/p&gt;
&lt;p&gt;A Login CSRF attack is when a malicious forces a user to sign into an account controlled by the attacker. Why do this? Because if that user then saves sensitive information the attacker can see it.&lt;/p&gt;
&lt;p&gt;Imagine I trick you into signing into an e-commerce account I control and saving your credit card details. I could then later sign in myself and buy things on your card!&lt;/p&gt;
&lt;p&gt;Here's how that would work: Say the site's login form makes a POST to &lt;code&gt;https://www.example.com/login&lt;/code&gt; with &lt;code&gt;username&lt;/code&gt; and &lt;code&gt;password&lt;/code&gt; as the form fields. If those credentials match, the site sets an authentication cookie.&lt;/p&gt;
&lt;p&gt;I can set up my evil website with the following form:&lt;/p&gt;
&lt;div class="highlight highlight-text-html-basic"&gt;&lt;pre&gt;&lt;span class="pl-kos"&gt;&amp;lt;&lt;/span&gt;&lt;span class="pl-ent"&gt;form&lt;/span&gt; &lt;span class="pl-c1"&gt;action&lt;/span&gt;="&lt;span class="pl-s"&gt;https://www.example.com/login&lt;/span&gt;"&lt;span class="pl-kos"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="pl-kos"&gt;&amp;lt;&lt;/span&gt;&lt;span class="pl-ent"&gt;input&lt;/span&gt; &lt;span class="pl-c1"&gt;type&lt;/span&gt;="&lt;span class="pl-s"&gt;hidden&lt;/span&gt;" &lt;span class="pl-c1"&gt;name&lt;/span&gt;="&lt;span class="pl-s"&gt;username&lt;/span&gt;" &lt;span class="pl-c1"&gt;value&lt;/span&gt;="&lt;span class="pl-s"&gt;my-username&lt;/span&gt;"&lt;span class="pl-kos"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="pl-kos"&gt;&amp;lt;&lt;/span&gt;&lt;span class="pl-ent"&gt;input&lt;/span&gt; &lt;span class="pl-c1"&gt;type&lt;/span&gt;="&lt;span class="pl-s"&gt;hidden&lt;/span&gt;" &lt;span class="pl-c1"&gt;name&lt;/span&gt;="&lt;span class="pl-s"&gt;password&lt;/span&gt;" &lt;span class="pl-c1"&gt;value&lt;/span&gt;="&lt;span class="pl-s"&gt;my-password&lt;/span&gt;"&lt;span class="pl-kos"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="pl-kos"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="pl-ent"&gt;form&lt;/span&gt;&lt;span class="pl-kos"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="pl-kos"&gt;&amp;lt;&lt;/span&gt;&lt;span class="pl-ent"&gt;script&lt;/span&gt;&lt;span class="pl-kos"&gt;&amp;gt;&lt;/span&gt;&lt;span class="pl-smi"&gt;document&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;forms&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-en"&gt;submit&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="pl-ent"&gt;script&lt;/span&gt;&lt;span class="pl-kos"&gt;&amp;gt;&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;I trick you into visiting my evil pge and you're now signed in to that site using an account that I control. I cross my fingers and hope you don't notice the "you are signed in as X" message in the UI.&lt;/p&gt;
&lt;p&gt;An interesting thing about Login CSRF is that, since it involves setting a cookie but not sending a cookie, &lt;code&gt;SameSite=Lax&lt;/code&gt; would seem to make no difference at all. You need to look to other mechanisms to protect against this attack.&lt;/p&gt;
&lt;p&gt;But actually, you can use &lt;code&gt;SameSite=Lax&lt;/code&gt; to prevent these. The trick is to only allow logins from users that are carrying at least one cookie which you have set in that way - since you know that those cookies could not have been sent if the user originated in a form on another site.&lt;/p&gt;
&lt;p&gt;Another (potentially better) option: check the &lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Origin"&gt;HTTP Origin header&lt;/a&gt; on the oncoming request.&lt;/p&gt;
&lt;h4 id="final-recommendations"&gt;Final recommendations&lt;/h4&gt;
&lt;p&gt;As an application developer, you should set all cookies with &lt;code&gt;SameSite=Lax&lt;/code&gt; unless you have a very good reason not to. Most web frameworks do this by default now - Django shipped &lt;a href="https://github.com/django/django/commit/9a56b4b13ed92d2d5bb00d6bdb905a73bc5f2f0a"&gt;support for this&lt;/a&gt; in &lt;a href="https://docs.djangoproject.com/en/3.2/releases/2.1/#requests-and-responses"&gt;Django 2.1&lt;/a&gt; in August 2018.&lt;/p&gt;
&lt;p&gt;Do you still need CSRF tokens as well? I think so: I don't like the idea of users who fire up an older browser (maybe borrowing an obsolete computer) being vulnerable to this attack, and I worry about the subdomain issue described above.&lt;/p&gt;
&lt;p&gt;And if you work for a browser vendor, please make it easier to find information on what the default behaviour is and when it was shipped!&lt;/p&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/chrome"&gt;chrome&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/cookies"&gt;cookies&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/csrf"&gt;csrf&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/security"&gt;security&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/samesite"&gt;samesite&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/starlette"&gt;starlette&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="chrome"/><category term="cookies"/><category term="csrf"/><category term="security"/><category term="samesite"/><category term="starlette"/></entry><entry><title>Datasette 0.31</title><link href="https://simonwillison.net/2019/Nov/12/datasette/#atom-tag" rel="alternate"/><published>2019-11-12T06:11:57+00:00</published><updated>2019-11-12T06:11:57+00:00</updated><id>https://simonwillison.net/2019/Nov/12/datasette/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://datasette.readthedocs.io/en/stable/changelog.html#v0-31"&gt;Datasette 0.31&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
Released today: this version adds compatibility with Python 3.8 and breaks compatibility with Python 3.5. Since Glitch support Python 3.7.3 now I decided I could finally give up on 3.5. This means Datasette can use f-strings now, but more importantly it opens up the opportunity to start taking advantage of Starlette, which makes all kinds of interesting new ASGI-based plugins much easier to build.


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/glitch"&gt;glitch&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/datasette"&gt;datasette&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/asgi"&gt;asgi&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/starlette"&gt;starlette&lt;/a&gt;&lt;/p&gt;



</summary><category term="glitch"/><category term="projects"/><category term="python"/><category term="datasette"/><category term="asgi"/><category term="starlette"/></entry><entry><title>Porting Datasette to ASGI, and Turtles all the way down</title><link href="https://simonwillison.net/2019/Jun/23/datasette-asgi/#atom-tag" rel="alternate"/><published>2019-06-23T21:39:00+00:00</published><updated>2019-06-23T21:39:00+00:00</updated><id>https://simonwillison.net/2019/Jun/23/datasette-asgi/#atom-tag</id><summary type="html">
    &lt;p&gt;This evening I finally closed a &lt;a href="https://simonwillison.net/tags/datasette/"&gt;Datasette&lt;/a&gt; issue that I opened more than 13 months ago: &lt;a href="https://github.com/simonw/datasette/issues/272"&gt;#272: Port Datasette to ASGI&lt;/a&gt;. A few notes on why this is such an important step for the project.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://asgi.readthedocs.io/"&gt;ASGI&lt;/a&gt; is the Asynchronous Server Gateway Interface standard. It’s been evolving steadily over the past few years under the guidance of Andrew Godwin. It’s intended as an asynchronous replacement for the venerable &lt;a href="https://wsgi.readthedocs.io/"&gt;WSGI&lt;/a&gt;.&lt;/p&gt;
&lt;h3&gt;&lt;a id="Turtles_all_the_way_down_6"&gt;&lt;/a&gt;Turtles all the way down&lt;/h3&gt;
&lt;p&gt;Ten years ago at EuroDjangoCon 2009 in Prague I gave a talk entitled &lt;a href="https://www.slideshare.net/simon/django-heresies"&gt;Django Heresies&lt;/a&gt;. After discussing some of the design decisions in Django that I didn’t think had aged well, I spent the last part of the talk talking about &lt;em&gt;Turtles all the way down&lt;/em&gt;. I &lt;a href="https://simonwillison.net/2009/May/19/djng/?#turtles-all-the-way-down"&gt;wrote that idea up here&lt;/a&gt; on my blog (see also &lt;a href="https://www.slideshare.net/simon/django-heresies/65-The_Django_Contract_A_view"&gt;these slides&lt;/a&gt;).&lt;/p&gt;
&lt;p&gt;The key idea was that Django would be more interesting if the core Django contract - a function that takes a request and returns a response - was extended to more places in the framework. The top level site, the reusable applications, middleware and URL routing could all share that same contract. Everything could be composed from the same raw building blocks.&lt;/p&gt;
&lt;p&gt;I’m excited about ASGI because it absolutely fits the &lt;em&gt;turtles all the way down&lt;/em&gt; model.&lt;/p&gt;
&lt;p&gt;The ASGI contract is an asynchronous function that takes three arguments:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;async def application(scope, receive, send):
    ...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;scope&lt;/code&gt; is a serializable dictionary providing the context for the current connection. &lt;code&gt;receive&lt;/code&gt; is an awaitable which can be used to recieve incoming messages. &lt;code&gt;send&lt;/code&gt; is an awaitable that can be used to send replies.&lt;/p&gt;
&lt;p&gt;It’s a pretty low-level set of primitives (and less obvious than a simple request/response) - and that’s because ASGI is about more than just the standard HTTP request/response cycle. This contract works for HTTP, WebSockets and potentially any other protocol that needs to asynchronously send and receive data.&lt;/p&gt;
&lt;p&gt;It’s an extremely elegant piece of protocol design, informed by Andrew’s experience with Django Channels, SOA protocols (we are co-workers at Eventbrite where we’ve both been heavily involved in Eventbrite’s &lt;a href="https://github.com/eventbrite/pysoa"&gt;SOA mechanism&lt;/a&gt;) and Andrew’s extensive conversations with other maintainers in the Python web community.&lt;/p&gt;
&lt;p&gt;The ASGI protocol really is turtles all the way down - it’s a simple, well defined contract which can be composed together to implement all kinds of interesting web architectural patterns.&lt;/p&gt;
&lt;p&gt;My &lt;a href="https://github.com/simonw/asgi-cors/"&gt;asgi-cors library&lt;/a&gt; was my first attempt at building an ASGI turtle. &lt;a href="https://github.com/simonw/asgi-cors/blob/master/asgi_cors.py"&gt;The implementation&lt;/a&gt; is a simple Python decorator which, when applied to another ASGI callable, adds HTTP CORS headers based on the parameters you pass to the decorator. The library has zero installation dependencies (it has test dependencies on pytest and friends) and can be used on any HTTP ASGI project.&lt;/p&gt;
&lt;p&gt;Building &lt;code&gt;asgi-cors&lt;/code&gt; completely sold me on ASGI as the turtle pattern I had been desiring for over a decade!&lt;/p&gt;
&lt;h3&gt;&lt;a id="Datasette_plugins_and_ASGI_31"&gt;&lt;/a&gt;Datasette plugins and ASGI&lt;/h3&gt;
&lt;p&gt;Which brings me to Datasette.&lt;/p&gt;
&lt;p&gt;One of the most promising components of Datasette is its plugin mechanism. Based on &lt;a href="https://pluggy.readthedocs.io/en/latest/"&gt;pluggy&lt;/a&gt; (extracted from pytest), &lt;a href="https://datasette.readthedocs.io/en/stable/plugins.html"&gt;Datasette Plugins&lt;/a&gt; allow new features to be added to Datasette without needing to change the underlying code. This means new features can be built, packaged and shipped entirely independently of the core project. A list of currently available plugins &lt;a href="https://datasette.readthedocs.io/en/latest/ecosystem.html#datasette-plugins"&gt;can be found here&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;WordPress is very solid blogging engine. Add in the plugin ecosystem around it and it can be used to build literally any CMS you can possibly imagine.&lt;/p&gt;
&lt;p&gt;My dream for Datasette is to apply the same model: I want a strong core for publishing and exploring data that’s enhanced by plugins to solve a huge array of data analysis, visualization and API-backed problems.&lt;/p&gt;
&lt;p&gt;Datasette has &lt;a href="https://datasette.readthedocs.io/en/latest/plugins.html#plugin-hooks"&gt;a range of plugin hooks already&lt;/a&gt;, but I’ve so far held back on implementing the most useful class of hooks: hooks that allow developers to add entirely new URL routes exposing completely custom functionality.&lt;/p&gt;
&lt;p&gt;The reason I held back is that I wanted to be confident that the contract I was offering was something I would continue to support moving forward. A plugin system isn’t much good if the core implementation keeps on changing in backwards-incompatible ways.&lt;/p&gt;
&lt;p&gt;ASGI is the exact contract I’ve been waiting for. It’s not quite ready yet, but you can follow &lt;a href="https://github.com/simonw/datasette/issues/520"&gt;#520: prepare_asgi plugin hook&lt;/a&gt; (thoughts and suggestions welcome!) to be the first to hear about this hook when it lands. I’m planning to use it to make my asgi-cors library available as a plugin, after which I’m excited to start exploring the idea of bringing authentication plugins to Datasette (and to the wider ASGI world in general).&lt;/p&gt;
&lt;p&gt;I’m hoping that many Datasette ASGI plugins will exist in a form that allows them to be used by other ASGI applications as well.&lt;/p&gt;
&lt;p&gt;I also plan to use ASGI to make components of Datasette itself available to other ASGI applications. If you just want a single instance of Datasette’s &lt;a href="https://datasette.readthedocs.io/en/stable/pages.html#table"&gt;table view&lt;/a&gt; to be embedded somewhere in your URL configuration you should be able to do that by routing traffic directly to the ASGI-compatible view class.&lt;/p&gt;
&lt;p&gt;I’m really excited about exploring the intersection of ASGI turtles-all-the-way-down and pluggy’s powerful mechanism for gluing components together. Both WSGI and Django’s reusable apps have attempted to create a reusable ecosystem in the past, to limited levels of success. Let’s see if ASGI can finally make the turtle dream come true.&lt;/p&gt;

&lt;h3&gt;&lt;a id="Further_reading_53"&gt;&lt;/a&gt;Further reading&lt;/h3&gt;
&lt;p&gt;&lt;a href="https://www.encode.io/articles/hello-asgi/"&gt;Hello ASGI&lt;/a&gt; by Tom Christie is the best introduction to ASGI I’ve seen. Tom is the author of the &lt;a href="https://www.uvicorn.org/"&gt;Uvicorn&lt;/a&gt; ASGI server (used by Datasette as-of this evening) and &lt;a href="https://www.starlette.io/"&gt;Starlette&lt;/a&gt;, a delightfully well-designd ASGI web framework. I’ve learned an enormous amount about ASGI by reading Tom’s code. Tom also gave &lt;a href="https://www.youtube.com/watch?v=u8GSFEg5lnU"&gt;a talk about ASGI&lt;/a&gt; at DjangoCon Europe a few months ago.&lt;/p&gt;
&lt;p&gt;If you haven’t read &lt;a href="https://www.aeracode.org/2018/06/04/django-async-roadmap/"&gt;A Django Async Roadmap&lt;/a&gt; by Andrew Godwin last year you should absolutely catch up. More than just talking about ASGI, Andrew sketches out a detailed and actionable plan for bringing asyncio to Django core. Andrew landeded &lt;a href="https://github.com/django/django/pull/11209"&gt;the first Django core ASGI code&lt;/a&gt; based on the plan just a few days ago.&lt;/p&gt;
&lt;p&gt;If you're interested in the details of Datasette's ASGI implementation, I posted &lt;a href="https://github.com/simonw/datasette/issues/272"&gt;detailed commentary on issue #272&lt;/a&gt; over the past thirteen months as I researched and finalized my approach. I added further commentary to &lt;a href="https://github.com/simonw/datasette/pull/518"&gt;the associated pull request&lt;/a&gt;, which gathers together the 34 commits it took to ship the feature (squashed into a single commit to master).&lt;/p&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/andrew-godwin"&gt;andrew-godwin&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/projects"&gt;projects&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette"&gt;datasette&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/asgi"&gt;asgi&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/kim-christie"&gt;kim-christie&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/pytest"&gt;pytest&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/starlette"&gt;starlette&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="andrew-godwin"/><category term="projects"/><category term="datasette"/><category term="asgi"/><category term="kim-christie"/><category term="pytest"/><category term="starlette"/></entry><entry><title>Automatically playing science communication games with transfer learning and fastai</title><link href="https://simonwillison.net/2018/Oct/29/transfer-learning/#atom-tag" rel="alternate"/><published>2018-10-29T03:16:33+00:00</published><updated>2018-10-29T03:16:33+00:00</updated><id>https://simonwillison.net/2018/Oct/29/transfer-learning/#atom-tag</id><summary type="html">
    &lt;p&gt;This weekend was the 9th annual &lt;a href="https://sf.sciencehackday.org/"&gt;Science Hack Day San Francisco&lt;/a&gt;, which was also the 100th Science Hack Day held worldwide.&lt;/p&gt;
&lt;p&gt;Natalie and I decided to combine our interests and build something fun.&lt;/p&gt;
&lt;p&gt;I’m currently enrolled in Jeremy Howard’s &lt;a href="http://course.fast.ai/"&gt;Deep Learning course&lt;/a&gt; so I figured this was a great opportunity to try out some computer vision.&lt;/p&gt;
&lt;p&gt;Natalie runs the &lt;a href="https://natbat.github.io/scicomm-calendar/"&gt;SciComm Games calendar&lt;/a&gt; and accompanying &lt;a href="https://twitter.com/SciCommGames"&gt;@SciCommGames&lt;/a&gt; bot to promote and catalogue science communication hashtag games on Twitter.&lt;/p&gt;
&lt;p&gt;Hashtag games? Natalie &lt;a href="https://natbat.github.io/scicomm-calendar/"&gt;explains them here&lt;/a&gt; - essentially they are games run by scientists on Twitter to foster public engagement around an animal or topic by challenging people to identify if a photo is a #cougarOrNot or participate in a #TrickyBirdID or identify #CrowOrNo or many others.&lt;/p&gt;
&lt;p&gt;Combining the two… we decided to build a bot that automatically plays these games using computer vision. So far it’s just trying #cougarOrNot - you can see the bot in action at &lt;a href="https://twitter.com/critter_vision/with_replies"&gt;@critter_vision&lt;/a&gt;.&lt;/p&gt;
&lt;h3&gt;&lt;a id="Training_data_from_iNaturalist_14"&gt;&lt;/a&gt;Training data from iNaturalist&lt;/h3&gt;
&lt;p&gt;In order to build a machine learning model, you need to start out with some training data.&lt;/p&gt;
&lt;p&gt;I’m a big fan of &lt;a href="https://www.inaturalist.org/"&gt;iNaturalist&lt;/a&gt;, a citizen science project that encourages users to upload photographs of wildlife (and plants) they have seen and have their observations verified by a community. Natalie and I used it to build &lt;a href="https://www.owlsnearme.com/"&gt;owlsnearme.com&lt;/a&gt; earlier this year - the API in particular is fantastic.&lt;/p&gt;
&lt;p&gt;iNaturalist has &lt;a href="https://www.inaturalist.org/observations?place_id=1&amp;amp;taxon_id=41944"&gt;over 5,000 verified sightings&lt;/a&gt; of felines (cougars, bobcats, domestic cats and more) in the USA.&lt;/p&gt;
&lt;p&gt;The raw data is available as &lt;a href="http://api.inaturalist.org/v1/observations?identified=true&amp;amp;photos=true&amp;amp;identifications=most_agree&amp;amp;quality_grade=research&amp;amp;order=desc&amp;amp;order_by=created_at&amp;amp;taxon_id=41944&amp;amp;place_id=1&amp;amp;per_page=200"&gt;a paginated JSON API&lt;/a&gt;. The &lt;a href="https://static.inaturalist.org/photos/27333309/medium.jpg"&gt;medium sized photos&lt;/a&gt; are just the right size for training a neural network.&lt;/p&gt;
&lt;p&gt;I started by grabbing 5,000 images and saving them to disk with a filename that reflected their identified species:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Bobcat_9005106.jpg
Domestic-Cat_10068710.jpg
Bobcat_15713672.jpg
Domestic-Cat_6755280.jpg
Mountain-Lion_9075705.jpg
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;&lt;a id="Building_a_model_32"&gt;&lt;/a&gt;Building a model&lt;/h3&gt;
&lt;p&gt;I’m only one week into the &lt;a href="http://www.fast.ai/"&gt;fast.ai&lt;/a&gt; course so this really isn’t particularly sophisticated yet, but it was just about good enough to power our hack.&lt;/p&gt;
&lt;p&gt;The main technique we are learning in the course is called &lt;a href="https://machinelearningmastery.com/transfer-learning-for-deep-learning/"&gt;transfer learning&lt;/a&gt;, and it really is shockingly effective. Instead of training a model from scratch you start out with a pre-trained model and use some extra labelled images to train a small number of extra layers.&lt;/p&gt;
&lt;p&gt;The initial model we are using is &lt;a href="https://www.kaggle.com/pytorch/resnet34"&gt;ResNet-34&lt;/a&gt;, a 34-layer neural network trained on 1,000 labelled categories in the &lt;a href="http://www.image-net.org/"&gt;ImageNet&lt;/a&gt; corpus.&lt;/p&gt;
&lt;p&gt;In class, we learned to use this technique to get 94% accuracy against the &lt;a href="http://www.robots.ox.ac.uk/~vgg/data/pets/"&gt;Oxford-IIIT Pet Dataset&lt;/a&gt; - around 7,000 images covering 12 cat breeds and 25 dog breeds. In 2012 the researchers at Oxford were able to get 59.21% using a sophisticated model - it 2018 we can get 94% with transfer learning and just a few lines of code.&lt;/p&gt;
&lt;p&gt;I started with an example provided in class, which loads and trains images from files on disk using a regular expression that extracts the labels from the filenames.&lt;/p&gt;
&lt;p&gt;My full Jupyter notebook is &lt;a href="https://github.com/simonw/cougar-or-not/blob/master/inaturalist-cats.ipynb"&gt;inaturalist-cats.ipynb&lt;/a&gt; - the key training code is as follows:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from fastai import *
from fastai.vision import *
cat_images_path = Path('/home/jupyter/.fastai/data/inaturalist-usa-cats/images')
cat_fnames = get_image_files(cat_images_path)
cat_data = ImageDataBunch.from_name_re(
    cat_images_path,
    cat_fnames,
    r'/([^/]+)_\d+.jpg$',
    ds_tfms=get_transforms(),
    size=224
)
cat_data.normalize(imagenet_stats)
cat_learn = ConvLearner(cat_data, models.resnet34, metrics=error_rate)
cat_learn.fit_one_cycle(4)
# Save the generated model to disk
cat_learn.save(&amp;quot;usa-inaturalist-cats&amp;quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Calling &lt;code&gt;cat_learn.save(&amp;quot;usa-inaturalist-cats&amp;quot;)&lt;/code&gt; created an 84MB file on disk at &lt;code&gt;/home/jupyter/.fastai/data/inaturalist-usa-cats/images/models/usa-inaturalist-cats.pth&lt;/code&gt; - I used &lt;code&gt;scp&lt;/code&gt; to copy that model down to my laptop.&lt;/p&gt;
&lt;p&gt;This model gave me a 24% error rate which is pretty terrible - others on the course have been getting error rates less than 10% for all kinds of interesting problems. My focus was to get a model deployed as an API though so I haven’t spent any additional time fine-tuning things yet.&lt;/p&gt;
&lt;h3&gt;&lt;a id="Deploying_the_model_as_an_API_67"&gt;&lt;/a&gt;Deploying the model as an API&lt;/h3&gt;
&lt;p&gt;The &lt;a href="https://github.com/fastai/fastai"&gt;fastai library&lt;/a&gt; strongly encourages training against a GPU, using &lt;a href="https://pytorch.org/"&gt;pytorch&lt;/a&gt; and &lt;a href="https://mathema.tician.de/software/pycuda/"&gt;PyCUDA&lt;/a&gt;. I’ve been using n1-highmem-8 Google Cloud Platform instance with an attached Tesla P4, then running everything in a Jupyter notebook there. This costs around $0.38 an hour - fine for a few hours of training, but way too expensive to permanently host a model.&lt;/p&gt;
&lt;p&gt;Thankfully, while a GPU is essential for productively training models it’s not nearly as important for evaluating them against new data. pytorch can run in CPU mode for that just fine on standard hardware, and the &lt;a href="https://github.com/fastai/fastai/blob/master/README.md"&gt;fastai README&lt;/a&gt; includes instructions on installing it for a CPU using pip.&lt;/p&gt;
&lt;p&gt;I started out by ensuring I could execute my generated model on my own laptop (since pytorch doesn’t yet work with the GPU built into the Macbook Pro). Once I had that working, I used the resulting code to write a tiny Starlette-powered API server. The code for that can be found in &lt;a href="https://github.com/simonw/cougar-or-not/blob/8adafac571aad3385317c76bd229448b3cdaa0ac/cougar.py"&gt;in cougar.py&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;fastai is under very heavy development and the latest version doesn’t quite have a clean way of loading a model from disk without also including the initial training images, so I had to hack around quite a bit to get this working using clues from &lt;a href="https://forums.fast.ai/"&gt;the fastai forums&lt;/a&gt;. I expect this to get much easier over the next few weeks as the library continues to evolve based on feedback from the current course.&lt;/p&gt;
&lt;p&gt;To deploy the API I wrote &lt;a href="https://github.com/simonw/cougar-or-not/blob/8adafac571aad3385317c76bd229448b3cdaa0ac/Dockerfile"&gt;a Dockerfile&lt;/a&gt; and shipped it to &lt;a href="https://zeit.co/now"&gt;Zeit Now&lt;/a&gt;. Now remains my go-to choice for this kind of project, though unfortunately their new (and brilliant) v2 platform imposes &lt;a href="https://github.com/zeit/now-cli/issues/1523"&gt;a 100MB image size limit&lt;/a&gt; - not nearly enough when the model file itself weights in at 83 MB. Thankfully it’s still possible to &lt;a href="https://github.com/simonw/cougar-or-not/commit/5ad3d5b49c6419e4c2440291bc5fb204625aae83"&gt;specify their v1 cloud&lt;/a&gt; which is more forgiving for larger applications.&lt;/p&gt;
&lt;p&gt;Here’s the result: an API which can accept either the URL to an image or an uploaded image file: &lt;a href="https://cougar-or-not.now.sh/"&gt;https://cougar-or-not.now.sh/&lt;/a&gt; - try it out with &lt;a href="https://cougar-or-not.now.sh/classify-url?url=https://upload.wikimedia.org/wikipedia/commons/9/9a/Oregon_Cougar_ODFW.JPG"&gt;a cougar&lt;/a&gt; and &lt;a href="https://cougar-or-not.now.sh/classify-url?url=https://upload.wikimedia.org/wikipedia/commons/thumb/d/dc/Bobcat2.jpg/1200px-Bobcat2.jpg"&gt;a bobcat&lt;/a&gt;.&lt;/p&gt;
&lt;h3&gt;&lt;a id="The_Twitter_Bot_81"&gt;&lt;/a&gt;The Twitter Bot&lt;/h3&gt;
&lt;p&gt;Natalie built &lt;a href="https://github.com/natbat/CritterVision"&gt;the Twitter bot&lt;/a&gt;. It runs as a scheduled task on Heroku and works by checking for new #cougarOrNot tweets from &lt;a href="https://twitter.com/drmichellelarue"&gt;Dr. Michelle LaRue&lt;/a&gt;, extracting any images, passing them to my API and replying with a tweet that summarizes the results. Take a look at &lt;a href="https://twitter.com/critter_vision/with_replies"&gt;its recent replies&lt;/a&gt; to get a feel for how well it is doing.&lt;/p&gt;
&lt;p&gt;Amusingly, Dr. LaRue frequently tweets memes to promote upcoming competitions and marks them with the same hashtag. The bot appears to think that most of the memes are bobcats! I should definitely spend some time tuning that model.&lt;/p&gt;
&lt;p&gt;Science Hack Day was great fun. A big thanks to the organizing team, and congrats to all of the other participants. I’m really looking forward to the next one.&lt;/p&gt;
&lt;p&gt;Plus… we won a medal!&lt;/p&gt;
&lt;blockquote class="twitter-tweet" data-lang="en"&gt;&lt;p lang="en" dir="ltr"&gt;Enjoyed &lt;a href="https://twitter.com/hashtag/scienceHackday?src=hash&amp;amp;ref_src=twsrc%5Etfw"&gt;#scienceHackday&lt;/a&gt; this weekend, made &amp;amp; launched a cool machine learning hack to process images &amp;amp; work out if they have a cougar in them or not! &lt;a href="https://twitter.com/hashtag/CougarOrNot?src=hash&amp;amp;ref_src=twsrc%5Etfw"&gt;#CougarOrNot&lt;/a&gt; &lt;a href="https://twitter.com/critter_vision?ref_src=twsrc%5Etfw"&gt;@critter_vision&lt;/a&gt;&lt;br /&gt;&lt;br /&gt;... we won a medal!&lt;br /&gt;&lt;br /&gt;Bot code: &lt;a href="https://t.co/W2jZcGCnFr"&gt;https://t.co/W2jZcGCnFr&lt;/a&gt;&lt;br /&gt;Machine learning API: &lt;a href="https://t.co/swNiKlcTp0"&gt;https://t.co/swNiKlcTp0&lt;/a&gt; &lt;a href="https://t.co/dcdIhNZy63"&gt;pic.twitter.com/dcdIhNZy63&lt;/a&gt;&lt;/p&gt;&amp;#8212; Natbat (@Natbat) &lt;a href="https://twitter.com/Natbat/status/1056717060116369410?ref_src=twsrc%5Etfw"&gt;October 29, 2018&lt;/a&gt;&lt;/blockquote&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/computer-vision"&gt;computer-vision&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/machine-learning"&gt;machine-learning&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/inaturalist"&gt;inaturalist&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/fastai"&gt;fastai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/transferlearning"&gt;transferlearning&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/jeremy-howard"&gt;jeremy-howard&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/starlette"&gt;starlette&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="computer-vision"/><category term="machine-learning"/><category term="natalie-downe"/><category term="inaturalist"/><category term="fastai"/><category term="transferlearning"/><category term="jeremy-howard"/><category term="starlette"/></entry></feed>