How InfraPhysics is built: a site with no server, organised around its wiki

InfraPhysics is a personal site built as one repository and one build: about 2,800 markdown files, a wiki of more than 1,500 notes among them, become static files served with a thin layer of edge functions. This write-up follows one paragraph from the keyboard to its five kinds of reader, then reconstructs from 1,148 commits the decisions that shaped each layer and what each one cost. It began in March as a diary of the first eighteen days and was rewritten in October.


I am not a web developer, and in January I set out to build a website anyway. This piece is about how it works now, nine months and 1,148 commits later: a site built around a wiki of more than 1,500 notes, compiled from markdown files by one build, with no database and no server that renders pages.

It explains the system by following one paragraph from my keyboard to the people and programs that read it, and then reads the commit history as what it mostly is: a record of approaches that were replaced, each with a cost. Two parts get sections of their own, because they are where most of the time went: the wiki, which is the reason the site exists, and the way the work is split between me and the coding models.

Backstory26 · jan 21Read noteI've been carrying a folder of notes around for almost ten years. Notebooks that start from both ends to fit two topics. Post-its that made sense for a week. Drawings with arrows connecting concepts, my own bootleg knowledge graphs, on paper, before I knew that's what they were called. TXTs and photos of whiteboards, about everything from AI to how industries scale. All of it scattered across devices and years. The real reason I'm building this site is that I finally have the tools to structure it all. The web stuff is a vehicle. The destination is the brain.

Rewritten26 · oct 20Read noteThe first version of this piece, from March, was a diary of the first eighteen days. Most of what it described has since been replaced, so it was rewritten around how the site works now; the diary entries that still say something true are kept in the notes beside the passages they belong to.

Why build it at all

The notes were never short of material; what they lacked was the connections between them. A note on how a CPU cache works and a note on why a transformer needs a key-value cache belong together, and in a notebook they can sit eighty pages apart. When language models became good enough to help me structure ten years of that, the obvious container was a set of linked notes, and the obvious way to make them worth keeping was to publish them next to the work they explain. The site exists to hold the wiki. The articles are the reason the wiki has to be right.

That gave the project its constraints before any code existed. It would be built and run by one person, so nothing could need a person to keep it alive at night. It would cost close to nothing, so no database to pay for and no server to patch. It had to be read by machines as well as by people, because a note that a search engine or a language model cannot read is a note that only I will ever find. And it had to stay a single thing I could hold in my head, which ruled out stitching a separate wiki onto a blog.

What came out of those constraints is easy to state. There is no database and no server that renders pages. The sources are about 2,800 markdown files in one repository; a build turns them into HTML and JSON; Cloudflare Pages serves the result as static files to a React application, and a thin layer of edge functions handles the few things static files cannot do. The rest of this piece is what that sentence costs.

Build log26 · jan 21Read noteBuild log26 · jan 21The cursor is blinking. I am not blinking.

A personal note26 · jan 24Built a horizontal navbar. It already feels wrong. Too flat.

A personal note26 · jan 25Rip out the navbar. Spend the whole day rebuilding as a sidebar. Break every layout assumption. Worth it.

Following one paragraph to its readers

The clearest way into the system is to follow one paragraph. Say I add a sentence to an essay, with a link to a wiki note and an image under it. That sentence will be read by five kinds of reader, and each of them wants something different from it:

A person in a browser wants a styled page

A search engine wants text it can index without running JavaScript

A link preview on WhatsApp wants a title and a picture in the first bytes it downloads

A language model wants plain text

The site's own assistant wants the page the reader has open, so it can answer questions about it

The journey has five moments, and each of the cards below is one of them, with what the paragraph has become by the end of it on the right.

01 Writing, on my machine: the paragraph is markdown, its image a master that git never sees

It starts with me or with an agent. Most of the time I describe what I want and a coding agent writes it, and before it touches anything it reads CLAUDE.md, the instruction file that tells it how this repository works (the subject of a later section). The sentence lands in a markdown file under src/data/pages/. If there is an image, I save its master in media/, a folder git never sees, and a few seconds later the development server has uploaded it to R2 as WebP and written its size and a hash of its content into a manifest that git does see.

02 Build, on my machine and in CI: the paragraph becomes HTML inside JSON, and a link to a note that does not exist stops everything

Then the build runs. It compiles the body with the compiler's own extensions, rendering code and mathematics at build time so the reader's browser never has to, and resolves every link. A link to a wiki note is written against the note's id, so the build can look it up, and an id that does not exist stops the build. The same pass checks the writing rules that a machine can check (no double quotes in prose, among others) and prints them as warnings on the line where they occur. Out come, among other files:

An index of every article

One JSON file per note

A manifest of every URL's head and share card

The text of every page

Two plain-text files for language models

Inside the compiler the order of the passes matters more than any one of them, and most of the bugs of the first months were passes in the wrong order. Code goes first and out of the way: every fenced block and every inline span becomes a placeholder, so nothing that follows can take a backtick, an asterisk or a bracket inside code for syntax. Then the site's own blocks (a typed box, or a folder tree like the one further down) are rendered to HTML and parked behind an HTML comment on a line of its own. They have to be parked because the markdown parser that runs next, marked, ends an HTML block at the first blank line and would read the rest of a parked block as ordinary text; once it has run, every comment is swapped back for its HTML. Footnotes are numbered after that, one paragraph at a time. Links to wiki notes are resolved last of all, by the build rather than the compiler, because a link can point at a note that has not been compiled yet, and only the build sees every document at once.

The build is also the reason a change of one word does not recompile two thousand files. Each file is cached under a hash of its own content and of the compiler's code: editing a note recompiles that note, and editing the compiler recompiles everything, which is the only safe answer when the rules themselves have changed.

For the record26 · mar 14Read noteOne thing worth clarifying: although the site runs on React, the content you're reading was not generated by your browser. Every article is compiled to HTML at build time, transformed into static HTML, and served as-is. Your browser just sits down and eats.

03 Commit, in the git hook: every URL's share card is a photograph, retaken only when something it shows has changed

When I commit, a git hook takes over. Every URL on the site has its own share card, the image a link shows when it is pasted into a chat, and those cards are photographs: the hook starts a headless Chrome and opens a design route of the site for each card that changed. It photographs the card and uploads it to R2, then stages the two manifests that point at the new cards into the same commit. Nothing about this needs me to remember it, which is the point.

A card is a page of the site like any other, drawn at 1,200 by 630 pixels in a route that exists only on the development server, which is why a headless browser can photograph it: the card uses the site's own fonts and covers, so it can never drift from the site's look. Its text is capped by length and never shrunk to fit, because a title set smaller to squeeze in is unreadable at the size a chat shows it. And the hook knows which cards to redraw because each one is recorded with a hash of everything it shows, its design included, so a commit that edits one essay photographs one card, and a commit that changes the card design photographs all of them.

04 Push, on GitHub and Cloudflare: GitHub checks the commit, Pages builds it again and publishes it

Then I push. GitHub runs the build and the tests on the commit, and Cloudflare builds the same commit again on its own and publishes it. The two builds are independent, which matters more than it seems: a broken link fails both, so it never reaches a reader, while a failing test only turns the check on GitHub red and the site is published anyway.

05 Every request, at the edge and then the reader: the same paragraph, five shapes

The last moment is the only one that happens every time someone arrives. A person's browser gets a small HTML shell from Pages and the application's JavaScript, and React draws the page from its JSON. A crawler gets the same shell, which on its own would be an empty <div>, so a Pages Function stands between the request and the file and writes the page's head and text into the shell before it leaves. The same function answers link previews, and it writes the share tags right after the first line of the head, because WhatsApp reads only the start of a page. Until late September it wrote them only for a request whose user agent called itself a bot, and any fetcher that did not (several do not) got the generic card of the home page; now the head is written for every request, and only the page's full text and its structured data are kept for crawlers. The assistant is the one reader that talks back: a question goes to an edge function, which sends it to the model with the text of the page the reader has open. And language models that want the whole site in one go read llms.txt and llms-full.txt, which the build regenerates every time.

The edge function is the part that patches the single-page app's one real weakness, and its core is short enough to read. This is the moment it decides what a URL is:

TS Copy

const entry = manifest?.[pathname]; if (!entry) { // Without the manifest nothing can be told apart: serve the shell as before rather than 404 every page. if (!manifest || APP_ONLY.test(pathname)) return env.ASSETS.fetch(new URL('/', request.url)); const shell = await env.ASSETS.fetch(new URL('/', request.url)); const headers = new Headers(shell.headers); headers.set('X-Robots-Tag', 'noindex'); return new Response(shell.body, { status: 404, headers }); }

A URL with an entry in the build's manifest is a page, and gets its head written. A URL in a short list of application routes (the admin panel, a redirect) gets the shell, and anything else is a real 404, with a header that tells search engines not to index it. Until October every unknown URL answered 200 with the lost-robot page the app draws, and to a search engine a page that says not found with status 200 is a page.

The repository

Everything in those cards lives in one repository, and its shape says a good deal about the decisions. These are the folders that hold code or decide behaviour, as they are on disk; the private material I draft in lives elsewhere and is left out of every count in this piece.

src/the application and its compiler components/React, about 110 files

views/one view per route

lib/content/blocks, links, validation

styles/one cascade of tokens

data/pages/the markdown sources

figures/illustrations, and the diagram kit

notebook/the Mandarin notebook page

scripts/48 build scripts build-content.jsdrives the compiler

check-references.jsfinds isolated notes

media.jsencodes and uploads images

og-cards.jsphotographs share cards

functions/crawlers, counters, assistant

workers/the counters Worker

media/ignoredimage masters

public/static files and indexes og-manifest.jsongeneratedevery URL's head

llms.txtgenerated

CLAUDE.mdinstructions for agents

AGENTS.mdpoints other agents to it

.claude/hooks and skills

.githooks/renders share cards

Two choices in that tree carry most of the weight. The first is that content and code live together. A link from an essay to a wiki note is checked by the same build that compiles both ends, and so is a link to a card in the second atlas, so a rename that breaks a link fails before it is published. A separate content system would have made each of those links a string that might or might not resolve. The second is the split between what git tracks and what it does not: image masters are too heavy for git and stay out, while the manifest that records every uploaded image stays in, so a build on a machine with no access to the bucket still knows every image the site uses.

In code the whole system is about 91,000 lines today, counted over the code of the site and its build, with the markdown and the generated JSON left out. Styles are about 17,000 of those lines, which is more than a frontend engineer would need and roughly what someone learning CSS by breaking it produces.

Nine months in four movements

The history has two pushes with a long silence between them, and the shape is worth seeing before the details. The week-by-week picture, commits coloured by the model that wrote them with the running count of code under them, is in the repository timeline.

The first movement is January. A React app with Tailwind loaded from its play CDN (which compiles CSS classes in the reader's browser), and a build script that grew a syntax extension every few days. It worked, and it was built the way you build when you do not yet know what will last.

Build log26 · jan 30Read noteThe compiler WORKS, wiki-links and all. I write this at 1:30pm and by 4pm I have wiki-link hover previews working. I can't stop clicking things.

The second, from February to March, is the first round of consequences. The notes were split into one file each and then renamed to stable ids, and the build learned to check links and to cache what had not changed. The first rules for coding agents were written after an agent improved code it had not been asked to touch. Comments and a graph explorer arrived too. About 410 commits in two months.

Then, from April to the end of August, almost nothing: articles, and one sweep that removed every em-dash from every text on the site. The line of code is flat for five months at about 30,000.

The fourth movement starts on 1 September with a commit that touches 410 files, and it is a different kind of work. In seven weeks the code goes from about 30,000 lines to about 91,000, and a wiki of a few hundred notes becomes one of more than 1,500. Almost every layer is redone in those weeks, from the way images reach the CDN to the wiki itself, rebuilt as a console with its layout in a worker. The busiest week, the one that starts on 28 September, has 309 commits. Most of the change of pace came from the tools, which the section on coding models comes back to.

The decisions that cost something

What follows are the turning points outside the wiki, grouped by the layer they changed. Each one started with something that had already gone wrong, and each one traded that problem for a smaller one; the smaller one is part of the story, because it is what anyone copying the decision would inherit.

Images stopped being uploaded by hand

Until September every image reached the CDN through a command I typed. I uploaded each one with wrangler, Cloudflare's command-line tool, under a name invented on the spot (6184744-banner.png), and pasted the URL into the article. When the CDN or a browser kept serving an old copy, I appended a version number by hand: one commit in July adds ?v=2 to a banner for exactly that reason. The repository knew nothing about any of it. It could not say which images existed, or whether a URL in a draft pointed at anything at all, and the only way to find out was to open the page.

The September pipeline replaced all of that with a convention and a manifest. A master is saved under media/ in a folder that says what it is for (an article's cover, or a figure in its body). The development server encodes and uploads it a few seconds after it is saved, and the manifest records what was uploaded:

JSON Copy

"articles/6184744/figures/band-01-writing.webp": { "src": "articles/6184744/figures/band-01-writing.png", "srcHash": "4b8d2e02ee2c", "v": "f50dfcb4", "width": 1400, "height": 735, "type": "image/webp" }

That entry is the first card above. The v field is a hash of the encoded file, and the build stamps it onto every URL of that image as ?v=f50dfcb4, so a changed image gets a new URL and no cache anywhere can serve the old one; nobody writes a version by hand again. The width and height let the page reserve the image's space before it loads, so text no longer jumps when a picture arrives. And because the manifest is in git while the masters are not, a build in CI (which has no credentials for the bucket) still knows every image, and prints a warning for any URL whose key is missing, which almost always means a master that was never pushed or a name that was mistyped.

By hand

Pipeline

wherever I saved it Where the master lives media/, by article and by kind

one command per file How it reaches R2 a few seconds after saving

a version typed by hand Cache busting a content hash stamped by the build

nothing What the repository knows every object, in a tracked manifest

a broken picture in production A missing image a warning at build time

The cost is a folder of masters that exists only on my machine and in the bucket, and needs a backup of its own; and one more moving part in the development server, which now uploads things when files are saved.

The single-page app stayed, and the edge pays for it

A single-page app sends every visitor the same nearly empty HTML and lets JavaScript build the page, which a search engine indexing a million pages or a link preview fetching one image never runs. Static generation, writing the full HTML of every page at build time, would have solved that at the root, and a framework like Astro would have been the better tool for a site that is mostly text. I kept the single-page app because parts of the site are applications more than pages (the wiki console, or the analytics panel), and one router and one way of thinking about all of them was worth more to me than the architectural purity of splitting them.

The price is paid at the edge, in the function shown earlier, and it has turned out to be a price of attention more than of code. Three times the edge failed silently in ways that only a person noticing something odd could catch. In September two new API endpoints shipped without their entry in the list of paths Cloudflare routes to functions. Every request to them got the app's HTML with status 200, which the client failed to parse in silence, so the visit counter on the home page sat frozen for a week. Later that month every WhatsApp preview came out bare while X showed the card, because WhatsApp fetches the image over TLS 1.2 and the CDN domain had its minimum set to 1.3. And the share tags only worked once they moved to the very top of the head, ahead of twenty kilobytes of inline scripts, because WhatsApp stops reading early.

Probe the live URL after anything that touches routing.

A request that never reaches its function is answered by a static file with status 200, so nothing looks broken. After a new endpoint or a new route, a curl against production that looks at the status and the Content-Type takes five seconds, and it is the only check that would have caught the frozen counter on the day it shipped.

Styling was decided late, on purpose

Colours never appear in components. They are custom properties defined once for each theme and used only through semantic names, so a component asks for the heading colour and never for a hex value. Tailwind stayed on its play CDN, compiling classes in the reader's browser, for eight months, and was compiled at build only on 2 October with the same version and configuration, so that nothing on screen would change. The move broke exactly one thing I had not foreseen: classes assembled from strings at runtime (bg-${color}) produce no CSS once a compiler has to find them in the source, so every dynamic colour became a custom property set on the element.

The assistant answers with the page open

The plus in the site's bar opens an assistant that answers questions about the site, and it is the one place where a stranger's text reaches a language model, so it was designed around two limits before it was designed around answers. The first is money. There is a monthly budget measured in a few dollars, and no day may spend more than a tenth of it. Every question reserves its worst case before the model is called (the longest conversation, nothing cached), so two questions at once can never overspend; when the month's budget is gone the assistant rests until the next one. The second is the reader's attention: each visitor has six questions a day, and the ring around the plus closes as they are spent.

Most questions never reach the model. The common ones (did an AI write this site?, what projects are there?) have prepared answers written by hand, joined into a small graph so that each answer offers the next questions worth asking, and the build checks them: a prepared answer that links to a page changed after the answer was last reviewed is reported, which is how this rewrite flagged two of them. A free question goes to the model with a system prompt that describes the site and, when the reader is on an article or a note, the full text of that page, capped in length. The model can link only to pages the build lists as existing; a link to anything else is dropped. Which model was decided by testing: 45 probe questions against two models, after which the larger one stayed, because the cheaper one answered worse on the same questions.

Things that were removed

A model makes a second and a third version of a design almost free, and this repository is full of their traces, such as the routes /s1 to /s8 for eight versions of one section, or ten versions of the home page's clock field. Each was built and compared on screen, then deleted once one was chosen. The losing versions were usually removed in the same week they lost, and that habit is most of what keeps the repository readable.

Some removals were of finished features. Comments, added in February because I wanted to learn how they work, were removed in September after one discussion and no comments in seven months; an end-of-article feedback form went the same way, and the contact page is the one way to write in. A feature built to learn a technology has done its job once the learning is done, and keeping it costs a loading gap and one more thing to maintain.

The wiki

The wiki is the part of the project I actually use every day, and the part every other decision serves. It holds more than 1,500 notes today, most of them on engineering (control, say, or machine learning), and its console, Undernotes, is the one part of the site that behaves like an application rather than a page.

The atlas, where the wiki opens: every domain is a way in, and the graph of all the notes is the faint ground behind the page

Where it comes from

I've been collecting notes since 2016. Not casual notes: obsessive knowledge collection, in every format I had. It started in college: an industrial engineering student who picked up software because every engineer should, and fell down the rabbit hole, from AI to chip architecture. Every topic opened four more topics.

The medium kept changing. First, physical notebooks that I'd start from both ends (front for one topic, back for another), trying to squeeze two subjects into one book. Then Post-its. Then conceptual drawings where I'd force myself to make them beautiful, because making something beautiful forces you to think before you draw. Then TXT files and markdown, then more TXT files because I forgot about the markdown files. Photos of whiteboards lived on my phone and never got organized. Every time I learned something, I'd write it down somewhere. The problem was that somewhere meant everywhere.

Aside26 · feb 03Read noteThe thing about collecting notes for ten years is that eventually the collection is the problem. You have hundreds of files across dozens of formats and you can feel how they connect: the structure is in your head, you just can't grep it. The whole point of this project is to take that latent graph and externalize it into something you can actually navigate.

The wiki still grows the same way. I paste in material of my own, at length, and a coding agent splits it into one note per concept and links each one to the rest; the agent stores what I wrote and adds nothing I did not say, and when the material has a gap it asks instead of filling it. On 9 October one batch of that kind added 481 notes in a single commit, with 445 questions written between them.

A note is a file with an id that never changes

A note is one concept in one markdown file: a frontmatter that gives it an id and an address in the hierarchy, then a body of a few paragraphs. The address uses // as its separator (web dev//rendering//SPA is the note on single-page apps, under rendering, under web dev), and the hierarchy exists only in the addresses: every file sits flat in one folder. The date is when the concept entered my study, never the day the file was typed, so a note on a 2023 paper is never dated before 2023.

A note as it reads: the body in the middle, and on the right everything the graph knows about it, computed at build

Everything around the body is computed, never written. The breadcrumb is the address, and the notes that mention this one are the reverse of every link in the wiki, collected at build. The column on the right lists the note's family and the notes it cites, and the thread at the foot steps through the wiki in the order the concepts entered my study. A note only has to say what it is and link what it uses, and the rest of its context appears by itself.

The wiki started as one file holding every note. Every edit to any note opened the whole thing, and every merge touched it. On 6 February the notes were split into one file each, and the build gained a cache keyed on each file's content so an untouched note is never recompiled. The second change, on 15 February, mattered more. Notes move (a concept turns out to belong under a different parent), and every move renamed a file and broke every link written against its old name. The files were renamed to random eight-character ids, with the address kept as a field inside the file, and every link in every article was rewritten against the id. A note can now move anywhere in the hierarchy and the only file that changes is its own.

Before

After

one shared file Where a note lives one file per note, named by a stable id

the note's address What a link points at the id; the address is a field

edit every file that links to it A rename one script, one file changed

shows as plain text A broken link fails the build

The cost is legibility: [[doG8Catf]] in the source means nothing to a person reading it, so the scripts that rename and move notes (they rewrite every reference after a dry run) became the only safe way to restructure the wiki, and the instruction file forbids doing it by hand.

Build log26 · feb 06Read noteThe validator catches 14 broken references on its first run. Fourteen. Half are wiki-links in posts pointing to fieldnotes I've renamed. Without the validator, those would be dead links in production for weeks.

In hindsight.

The browser editor built in February (a code editor on the development server, with a small API that wrote the files back) was the wrong investment. By September every note was edited as a file, by agents and by me in an ordinary editor, and the in-browser one was deleted inside a commit about something else. Files were the interface all along, and the editor was a second interface to keep in step with the first.

Relations are sentences and questions

Two notes relate in two ways. The ordinary one is a link in a sentence, where the sentence says how they relate. The other is a question: a file of its own that asks something joining two notes (why is the worst-case execution time not a 99th percentile?) and answers it, so the relation is a thing a reader can wonder about rather than an edge with a label. There are 560 of them. Until 6 October every note also ended in a list of bare connections, each with a one-line annotation; the 667 of them were rewritten as prose or as questions wherever they carried something, because a list of links reads like an index and a sentence reads like an explanation.

The build checks the graph as a whole on every run, and a link to a note that does not exist fails it. A separate script lists the notes nothing links to and the parents that exist only as a word in someone's address.

Undernotes, and why the graph is the heavy part

The console draws the whole graph of notes as the ground of the page, with a search line that takes tokens such as root: and under:, and the note itself beside it. The search line holds the whole query as one string, so root:Control is:bridge sort:central is a view that can be shared as a URL, and the map can be raised from the ground into a workspace of its own.

The map raised, laid out by a simulation that runs in a worker Almost every performance problem the site has had came from here, because a reader opening the wiki needs every note's name and links before the graph can be drawn, and the graph has to be laid out, which means running a physical simulation in which links pull notes together and every note pushes the others away until the whole thing settles.

The index started as a file bundled into the JavaScript and was fetched over HTTP from February, until in October it became two: every note without its body first, the bodies later, when the page goes idle or when a search needs them. The October split was forced by a measurement, and the measurement was embarrassing. A piece of the old console's state wrapped the whole application, so every first visit to any page of the site downloaded about 2.8 MB of wiki it never showed, and the tab titles came from a three-megabyte manifest fetched on every change of page. Both were replaced by files the size of what they serve.

The layout was the other half. The first 160 steps of the simulation took about a second on a laptop and nine seconds with the CPU throttled four times, in one uninterrupted task, so the page froze while the wiki opened. In September the layout moved into a web worker, a separate thread that shares the forces with the page through one module, and a layout that has settled is now saved as a seed (in the browser, or precomputed by the build) and reused the next time, keyed by a fingerprint of the exact notes and links so that a stale seed is never used. A note opened from a link in an article does not wait for any of this: it draws itself from its own small file first, and the console takes over underneath without moving the text by a pixel.

Every one of these changes left the work the same size and moved it somewhere the reader is not watching, such as the build or a worker. That pattern is the closest thing this project has to a performance strategy.

Working with coding models and two machines

The site was built with coding models from its second week, and most of its code was written by them. This section is about how that work is organised, and how two machines and several sessions share one repository without stepping on each other.

Who wrote the commits

Each commit an agent writes can carry a trailer that names the model, and five models appear in them.

Model in the trailer First commit Last commit Commits

Claude Opus 4.6 Mar 12 Mar 12 1

Claude Opus 4.8 Jun 27 Jul 6 33

Claude Fable 5.1 Sep 6 Sep 21 82

Claude Opus 5 Sep 17 Sep 17 1

Claude Opus 5.5 Sep 25 Oct 20 570

No trailer Jan 21 Oct 20 463

The table undercounts in two directions. Until March the trailers were switched off on purpose (a commit on 6 February told the commit routine to leave them out), so the 400-odd commits of the first push were written with an agent and show nothing. And Codex, which also worked on the repository, signs nothing at all. What the table does show is that the second push is almost entirely the work of models that arrived during it, which is why the line of code climbs the way it does in the timeline.

The instruction file is an interface

CLAUDE.md is touched by 217 commits, more than any other file in the repository, and it reads like a specification written for a colleague with no memory. The first rule, from February, said to do only what was requested and nothing more, after a one-line fix to a border colour came back with the theme logic rewritten. The rule took three drafts, because the first two (try not to change things you weren't asked to change, then only modify files directly related to the request) were read as permission. Over nine months the file grew three kinds of content:

Rules triggered by events (a note is renamed, so run the rename script; an article is edited, so run the build)

The architecture, described in the words the code uses

A long section of gotchas, each one a bug that happened at least once, written down so the next session does not repeat it

A one-line AGENTS.md points every other agent, Codex among them, to the same file, so there is one source of truth and no second copy to drift.

Build log26 · feb 07Read noteThe AI adds comments to a file I ask it to leave alone. We're getting there.

The rules that matter most are the ones that run. Hooks remind an agent to run the build after it edits a note, and the git hook regenerates share cards whatever wrote the commit. Two skills package procedures too long to keep in the instruction file: reviewing an article against its writing rules and its sources, and reviewing the parts of the site that go stale. The writing rules themselves are checked by the build, which turned style from a request into a test. And the commit messages changed for the same reason: until late September they followed the conventional feat(scope): form, and from then on they are full sentences that say what changed for a reader of the site and why, because by then the next reader of a commit message was usually an agent starting a session with nothing but the history to go on.

What went wrong

The gotchas are the best evidence of what working with models is like, because they record the failures a model makes and a person would make at most once. One describes a bug that only existed in development: an animation that set React state on every frame kept the router's pending navigation from ever completing, so the URL changed and the page did not, which looked random until someone traced it. Another describes a test script that started its own development server, shared the cache of the one already running and quietly broke every open tab. A third describes a replacement written as a JavaScript string that turned \b and \t into control characters, so two notes shipped with broken formulas and no build error.

What changed most over the nine months is where the effort goes. Writing the code stopped being the expensive part some time in September. The cost moved to deciding what to keep and to deleting what lost, and to writing down, in a file the next agent reads first, why the survivors are the way they are. The diagrams in this piece are an example: they were drawn by a small kit of my own from a short description of each card, after an image model had produced a first version that looked right and got four facts about the system wrong.

Two machines and several sessions in one tree

The history has two authors who are the same person: one identity until September and another from September on, the second machine, with merges that bring its work together with the first. Since 20 October the laptop at home is also reachable from my phone over Tailscale, without a single port open on the router, so a session with Claude Code can be started and answered from anywhere; how that connection works, layer by layer is a piece of its own. On top of that, several agent sessions often work in the same checkout at once, each on its own task, and that is where the subtle failures come from. Two sessions staged each other's changes more than once, and once a push published commits another session had left for my review. The rules now are three:

Every session stages only its own hunks and commits with explicit paths

It checks what is waiting to be pushed before it pushes

When someone else's work sits unpushed, it builds its own commit in a separate worktree instead of pushing over it

What stays private

The details of the security stay out of this piece on purpose, but the shape can be said. No credential has ever been committed: tokens live in files git ignores and in Cloudflare's secret store, and the documentation names them without their values. The repository is private, because it also holds the workshop where articles are drafted and study material is kept. The assistant is the one place where a stranger's text reaches a language model, so it is the one place with hard limits, a daily count of questions per visitor and a monthly budget it cannot pass. The text of the open page goes with each question but capped in length, since that text is the obvious door for prompt injection. The HTML the compiler produces is sanitised after every transformation, never before, and the analytics panel runs under a content security policy strict enough to rule out every chart library, which is why its charts are drawn by hand.

What keeps it true

A site like this decays in a specific way: through things that were true on the day they were written.

The text crawlers see for the home page is a copy of what the home page renders.

The frame of llms.txt is written by hand around its generated lists.

The Mandarin notebook is a snapshot of a deck at one version.

The CV makes claims about the present.

Since the end of September each of these has an entry in UPKEEP.md that says where it is and how to check it, and npm run upkeep lists what is due. The rule that came with it is the most useful one in the repository: derive a living part from its source whenever that is possible, and register it, in the same commit, when it is not.

The one decision I have not revisited is the first, the single-page app, and the history explains why. Every problem it caused was solved at the edge in an afternoon, while moving to static generation would touch every view. If I were starting today with what the last nine months taught me, the articles would be static pages and the parts that behave like applications would live inside them. I am not starting today, and the function that patches the difference is under 300 lines long.