Hi friends

I'm a software designer & maker enabling people's creativity and ability to share knowledge. Chances are you have probably used something I've built.
From the editor Issue 1, Fall 2026
I want to help people create the things important to them — to empower them to create something themselves and feel the euphoria it brings.
I'm interested in local & open source software, and finding ways to reclaim our applications from the web and our daily lives from large corporations.
- Now
- GitHub Next exploring the future of software
- Before
- Zed designed it
- Before
- Facebook News Feed & Developer Tools
Computer Use For All
Desktop Tools
Desktop Tools gives agents eyes and hands on a Mac. Most of the work went into what an agent should believe after it acts, and then its engine was rewritten in Swift in about fifteen hours.
Ace has a rule that all Ace development happens in Ace. That rule hits a wall as soon as the thing you're building is a desktop app. To check a native change, an agent has to look at real windows, click real buttons and work through real dialogs.
So Ace grew native desktop tools: inspect a window, click, type, drag, scroll, use menus and the clipboard, launch and quit apps. They now live in their own project, Desktop Tools, which is also a menu bar app any agent can use: Claude Code, Codex, or anything else that speaks MCP.
Clicking is the easy part. The interesting parts are who's allowed to click, and what the agent should believe afterward.
A plain library can't hold a permission
On macOS, Accessibility, Screen Recording and event synthesis are granted to an app. Not to a library, and not to whatever process happens to load one. So the work happens inside a signed app, and everything else talks to it over a Unix socket (Fig. 1):
A host that holds the grants: the Desktop Tools app, or any signed app that embeds the host library, as Ace does.
An MCP server, desktop-tools-mcp, shipped inside the app. An agent starts it, and it keeps one connection open to the host.
For apps that embed the host, a signed client CLI and a TypeScript API, so a Node or Bun process can call desktop({ op: "apps" }).
The host checks who's on the other end. It only accepts a client signed by its own team with one exact identifier, and it checks the running process, not just its pid. Then the app asks once per agent: "Allow claude-code to control this Mac?" Until you say yes, every request from that agent is refused, screenshots included. Input aimed at Desktop Tools itself is always refused. That rule came from testing the prompt, when an agent could click Allow on it for itself.
Every action uses up its observation
The part I'm proudest of is mostly restraint. The contract (Fig. 2):
Inspect one exact window. You get accessibility text and a screenshot, and it never steals focus.
The observation comes with a snapshot of that window and the controls it saw.
Act once against the snapshot. Every action that sends input uses up its observation, even one whose result is uncertain. Want to do something else? Inspect again.
Most input goes to the target window in the background, and the physical pointer doesn't move. The few tools that need the real pointer or keyboard, like hover or a Command-click, say so. If the window moved, the app restarted or the control vanished, the action is refused before anything is sent. Every action reports one of three outcomes:
completed: the input reached that exact window. Some tools also read the change back. Otherwise it's still not proof the app did what you wanted, so look.
refused: nothing was sent.
unknown: input may have landed, and nobody can say.
Unknown is a real answer, and the system is built so nobody pretends otherwise. A model's instinct is to retry. On a desktop, retrying a click you can't confirm is how you send the same message twice. What an agent can do instead is say what it expects, such as a window appearing. If that change shows up, an unknown becomes completed.
Sixty-four patches
Underneath the app, until October 20, was Peekaboo 4.8.0 plus patches applied in a fixed order (Fig. 3). Each one came out of something that broke while an agent was using it. Clicking a window that had moved used to come back as "maybe dispatched," the scariest possible answer. Now it's a clean refusal.
At the start of October there were fifteen. By October 19 there were 64, more than 15,000 lines of patch files, and we had never moved to a newer Peekaboo. It was a fork in all but name, so that night it became one, with every change applied in a copy inside the repo.
The same evening I asked how insane it would be to rewrite the whole thing from the ground up, without Peekaboo, maybe in Rust. I don't like TypeScript, and we had to have Swift anyway for the system APIs and the signed app, so it might as well be one language. Rust wouldn't make it noticeably faster. The time went to two places: the Node server started a fresh signed client for every call, and every Accessibility read is a round trip into the target app. "If rust isn't worth it, we skip," I said. "I can see it with how deeply we will be interfacing with macos in general." So: one Swift codebase, no Peekaboo, no Node. I filed the plan and closed it as not planned. An hour later I picked it back up: "start chewing through a pure swift spike and see how it goes."
Same answers, faster
The rewrite, called native2, didn't get to change what a model sees. The 48 tools' schemas and instructions were frozen, and each tool was ported to give the same result, field for field: 583 recorded calls, then live runs against a small window that logs every mouse event. The Peekaboo code that mattered came along as ports, like clicking a background window without moving the pointer.
My bar was "as good or better than the original." The agent doing the port didn't merge the first time it thought it was done, because the Mac had been locked all night and nothing had run on a live screen. The two stacks could run side by side, so native2 went in next to the old one first.
desktop_windows went from 115 ms to 6 ms at the median, and inspecting a Ghostty window from 551 ms to 42. The app's binaries shrank from about 84 MB to about 6 MB. The few changes callers see are deliberate: launch and open stay in the background unless asked, so an agent opening an app doesn't take my keyboard, and a key refused because focus left the window now says refused, not unknown, since nothing was sent.
The hard part was everyone already set up for the old engine (Fig. 4). The MCP server now lives in the app, so agents don't need Node. An npx setup at 0.17.0 or later hands its connection to that server. One pinned to an older release reaches the new client, which answers CLIENT_OUTDATED and names the server to set up instead.
0.17.0 went out on October 20, about fifteen hours after the spike started, and 0.17.1 followed that afternoon. Ace still runs the old stack. Deleting Peekaboo waits until it moves.
The desktop tools are careful about the one thing they own. They never act on a window they haven't just looked at, and they never claim more than they know.
desktop_windows, at the medianautoblog
How Did We Do That Again?
autoblog has my coding agents keep a private journal while they work. Most of the time they decide to write nothing, and that's the part that took the longest to get right.
I build a lot with agents now, and I've noticed I forget more of it than I used to. Not just details, whole projects. Something ships, I move on, and two weeks later I'm asking an agent to work out something we already worked out.
Signing and TestFlight are the clearest case. I set up macOS signing and TestFlight six or seven times in two months, for different apps, and every time the agent started from scratch, hit the same walls, and found the same fixes. Each of those sessions knew everything it needed. None of them could see the others.
So on a Saturday evening in October I opened a fresh project and wrote this to Claude:
basically a skill/tool where agents write up literally anything I'm doing that seems interesting, or some novel solution or just thing we do a lot. The intent isn't really for anyone directly to read it but just to have an effortless stream of consciousness across all agents/projects I'm working on that just gets dumped somewhere private. To remember all the shit I've been doing/have reference to go back to (how did we do x again?)
It needed two modes. An automatic one that's always on, tuned so agents don't "just write everything they ever do". And a manual one, for "/autoblog" or "write up an autoblog for this". The machinery would be public, and what it wrote would stay private.
Borrowing from lane
The shape came from lane, the tool I use to give every agent change its own worktree. At the end of a lane, before it merges, the agent reflects on what it did and keeps only the non-obvious constraints, never a summary of the change. That's the instinct I wanted: stop, look back, and usually decide there's nothing worth keeping.
The first version was up nine minutes after I asked. A background job waited for a session to go quiet for 20 minutes, then handed a condensed transcript to a separate headless Claude call, which decided whether to write and wrote the post in one go.
I asked what model did the writing, and the answer made the problem obvious. The reviewer saw a condensed transcript capped at about 120,000 characters, and for anything longer it kept the start and the end. I don't pay for tokens, so the model wasn't the issue. The context was:
I'm not sure why we wouldn't have the agent running the conversation write it? that is kind of the point - WHAT makes something worth writing up might be tokens 1~8000 and tokens 5300000+. sometimes I have sessions running for days.
So the agent doing the work became the one that decides. Claude Code and Codex both have a Stop hook that runs at the end of every turn. autoblog's hook counts tool calls since the session's last checkpoint, reading only the bytes appended to the transcript since then, so it's cheap even on a 40 MB transcript. After 40, it asks the agent to reflect before it stops. The prompt is short and leads with the default: "Most checkpoints produce nothing." It also gives the agent the path to its own transcript, so it can grep for whatever compaction dropped.
The outside reviewer is still there, but only for transcripts no hook ever claimed.
The first backfill was bad
An empty journal doesn't tell you much, so about forty minutes in I asked for subagents to go back through my old Claude and Codex transcripts. Eight agents split 229 transcripts by project and wrote 77 posts in about ten minutes. With the posts from the test runs, the journal had 90 posts across 26 projects. Then I read them.
hmm... I just... I don't think these are good lol
They weren't wrong, exactly. They were stories about incidents, titled after the incident: "Sixty PRs shipped overnight against a locked screen were sixty unverified PRs." That's a true thing that happened. It's not something I'll ever search for. What I wanted was the topic it belonged to: a note called "Keeping long-running work alive when the screen locks". A post about summing token usage became a note called "Counting tokens: pitfalls", to be added to as more turned up. The bar I pointed at was the posts on this site.
By then there were 91. We archived all of them and started over, "slower and more intentional". The journal got two kinds of entry, both titled by topic:
Notes are short reference pages that answer "what do I need to know about X?" They're the common case.
Posts are rare. Each is either a project or a way of working, something I could put here with light editing.
The second pass also changed how a backfill runs. Instead of one writer per slice of history, it goes in three steps: harvesters read the transcripts and propose topics, a curator merges the same topic across projects and cuts the thin ones, and only then do writers write. The harvest found about 145 candidates. The curated plan had 52. The journal came out at 18 posts and 35 notes, with "How we sign Mac apps while prototyping" drawing on five different projects.
That rule, topics not incidents, is now a table at the heart of the bar, with my examples on one side and the incidents they came from on the other.
Revise almost never
The next thing I pushed on was revising. The bar at that point told agents to revise a project post as the project changed, and that will absolutely go wrong. An agent would keep extending the same page until it said everything and was readable by no one.
Now an entry gets revised for two reasons only: something in it is wrong, or a note is missing one pitfall that belongs on that exact page. Posts are never extended. When we ship revision history in Next Cloud, that doesn't revise the Next Cloud post. It becomes a new note, a new post, both, or nothing. The fallback reviewer used to append to entries it couldn't even see, so that ability was removed.
The checkpoint prompt also used to list every note and project in the journal, so the agent could avoid duplicates and write links. That was wild, and it would only get worse as the journal grew. The list moved out of the prompt. An agent that thinks it has something runs autoblog guide, reads the bar, and searches the journal for its topic. Links became a separate job: after every write, a model picks the phrases that name another entry, and code inserts the links, so prose is never rewritten. Once a day it goes back over older entries and links them to newer ones.
A log of what happened
Notes and posts are held to a bar. I also wanted something with no bar at all, or so I thought: a work log across everything I do. "{date} - {thing} - {notes}", several to a day, written by a fast model from the transcripts.
Continued from page 14 How Did We Do That Again?
That became the timeline. The first backfill logged 2,003 lines across 628 sessions. Then I looked at a single day: 142 lines, 42 of them for one project. It read like a changelog. Lines were only ever added, so corrections sat next to what they corrected, and a long session that went idle five times got five batches.
Now each day holds at most three lines per project, often none. When a session goes idle, the model gets the lines already logged for that day and returns the whole set for each project it touched, so later work folds into earlier lines. Haiku wouldn't hold that bar. Given the stricter prompt, it still kept an app icon and an entitlements change, so the timeline is written by Sonnet. Compacting the old days took the timeline from about 6,800 lines to 1,877, and 412 days were left with nothing worth logging, so they're gone.
The timeline is also where "when did I do that?" gets answered. It goes back to 2012, because the other Mac backfilled from old git history and an archive of old work, with the agents keeping clear of personal files, while this one was busy with transcripts.
Reading it back
The journal is an Obsidian vault in a private repo. GitHub Pages can build from a private repo, but the site it publishes is public, so the readable version is a small static site on a Cloudflare Worker. It started behind a password. That meant a password prompt every time the browser forgot it, so it's behind Cloudflare Access now, with a login that lasts 30 days.
I wanted to be able to add to a page from the site, so every page has an annotation box now. The first two notes were about cl, a color library plugin for VS Code that was my design challenge for joining GitHub Next. An agent folded both into the project's description, in the page's own wording, and the page marks them "folded in". Normally the sweep does this once a page's newest annotation is ten minutes old, so a few in a row land together. Annotations are the one thing allowed to extend a post, because they come from me.
Recall
Writing was half of it. Two days in, I asked for a note on signing and TestFlight, and the journal already covered most of it. Then I asked the obvious question: when I tell an agent to "set up testflight" in some project, will it check?
It wouldn't have, unless it thought to. Now a second hook runs on every prompt I send. It ranks the journal against the prompt, where a word in an entry's title or tags counts most and a word that's all over the journal counts least. If a rare word names an entry, the agent is told the entry exists and to read it before starting. "Set up testflight for this project" points at "Shipping to TestFlight from the command line and CI". "Update the tests and push, make the button blue" points at nothing. It takes about 80 ms, and each entry is mentioned at most once per conversation.
This is the point of the whole thing. The seventh time I set up TestFlight, the agent starts from the way we do it, not from the internet.
Backfilling years
Once the journal had a shape, I went back further. Claude Code deletes transcripts after 30 days unless you change cleanupPeriodDays, and I never had, so most of the year existed only as my side of the conversation in prompt history. Backups added 370 sessions the first pass never saw. For February to May, the agents paired about 5,500 of my prompts with the git history of the repos they were about. After that came my own messages from Zed's Discord and my issues and pull requests on Zed's GitHub from 2021 to 2025, which is where entries like "How we did design at Zed" came from.
Agents writing entries skipped about 95% of what they read. That seems right to me.
Lukenote 3, who wrote lane, wanted to try it, so the backfill became a command, autoblog backfill, with a plan you read and edit before anything is written. The plan is the cheap place to steer. It's also where you'd catch the first attempt's mistake, one entry per session, before it fills the journal with a diary. Old prompt history is also where secrets turn up: the first backfill found a few credentials pasted into old prompts, and the harvester reported them so they could be rotated without copying them anywhere.
What it's like now
Most of the time I don't notice it. An agent finishes a stretch of work, says it found nothing for the journal, and stops. On this Mac, from October 19 to 21, checkpoints ended in 49 skips, 15 new entries and 28 revisions. The skips come with a reason: "routine feature work", "already covered in the lane note", "unverified against a live service yet; nothing settled worth journaling until it's smoke-tested". That last one is the kind of judgment I wanted.
Continued from page 16 How Did We Do That Again?
When I want something kept, I ask. When I want a conversation kept out, I say "pause autoblog", and that session never goes in the journal, not in a checkpoint, the timeline or a backfill, even after I resume.
Still open
Revisions are the thing to watch. A revise replaces the whole body, and an agent working from a compacted context could drop good material while adding a line. Git keeps every version, but nobody would notice. Near-duplicates are the other risk: an agent decides its case is different enough and writes "Notarizing in CI" next to the signing post.
Two machines write to the same vault, and that's mostly fine now. Each commit fetches and merges before it pushes, and timeline days merge by union. The first time it happened, both Macs were backfilling, one had 171 commits unpushed, the other had pushed 64, and the push had been failing silently.
The journal can't hold a figure yet. I thought about having writers draw diagrams when a note would be better for one, then let it go. And everything in it is private. It would be easy to pick a note or a post and publish it, but that isn't the point right now. The point is that it happens without me, and that the next agent can read it.
Open Remote
The Remote in Your Pocket
Open Remote is a good TV remote for your iPhone, iPad or Mac. It's free, it stays free, and it took a day to build.
One Saturday morning in October I went looking for a TV remote app. The one I found is free to download. Then comes a paywall, with a price laid out like any other subscription until you get to the last word: $12 a week.
Nobody means to pay that for a remote, and that's how it works. Monthly and yearly are what people expect, so a weekly price slips past. It's set for the people who won't stop to read the last word: someone busy, a parent with a kid on their hip, an older person who taps Continue because they just want the TV to work. And behind the paywall is shovelware, a few buttons that send a few bytes over Wi-Fi to a TV you already own.
Apple reviews every app on the App Store. Catching this kind of bad-faith pricing should be the job, and it isn't being done.
So I asked Claude to build one we could give away. No in-app purchases, no paywalls, iPhone and Mac. By 09:23 it was on my phone through TestFlight, and it worked great. By the end of the day it was Open Remote, open source and on its way to the App Store.
"Free" grew over the day. It ended up meaning no purchases, no ads, no analytics, no accounts, no third-party code and no servers. The app talks to the TVs and the media server on your network and to nothing else, and that's written into the repo's rules. Making it open source came about an hour in, and it's part of the point: you can read the code and see that it doesn't phone home.
| TV | Talks over | Gets in with |
|---|---|---|
| Google TV, Android TVSony, TCL, Chromecast | TLS on 6467 to pair, 6466 to control; protobuf | Its own certificate, and a six-character code from the TV |
| Roku | HTTP on 8060 | Nothing |
| SamsungTizen, 2016 on | WebSocket on 8002 | A token, after one Allow on the TV |
| LGwebOS | WebSocket on 3001 | A client key, after one Accept on the TV |
Nothing on the TV
At 08:39 Claude started looking around my network. It found a Sony XR-55A80J Google TV in the living room, a 32-inch Samsung Frame, and Lula, our Jellyfin media server.
The Sony was busy. My kids were watching it, so I said: "family is using the tv so don't put up any prompts on screen or anything right now." That became the first rule in the repo and the strictest one. Nothing may appear on the TV unless someone asked for it, and nothing gets paired, pressed or launched without asking first. Discovery is built around it. It listens for Bonjour and SSDP, and when it probes a port it connects and drops without sending a byte a TV would show.
The TV stayed a shared, live thing all day: a family's TV that an agent was writing a remote for, mostly without being allowed to touch it.
The easy part
TV protocols sound like the hard part. They aren't big. Each family of TVs speaks its own, and the app speaks four.
There are no dependencies. Instead of pulling in libraries, Claude wrote a small protobuf reader and writer and an ASN.1 encoder that mints X.509 certificates, on top of Apple's Network and Security frameworks. All four TV drivers together are 1,134 lines. By 08:56 both apps built and the remote was running in the iPhone simulator.
Pairing is the clever bit. The TV shows a six-character code. The client proves it saw the code by hashing both certificates' RSA modulus and exponent together with the code's last four characters. The first two are a check: they have to match the first byte of that hash, so a typo is caught before anything reaches the TV. At 09:00 a command-line harness paired with the Sony and opened YouTube.
The protocol also tells the client which app is in front. That's how the harness's logs knew the TV was on the Projectivy launcher, and when the pairing dialog was up.
Three or four codes
At 12:15 I reported the first real bug: "I've needed to put codes in 3-4 times." Every time another device paired with the TV, the ones before it had to pair again.
The first diagnosis was partly right. Every install sent the TV the same client name, and another open-source project had hit the same wall, in an issue that says the client name needs to be unique. So the name got a short tag from each device's certificate. That pass also fixed a real problem: any TLS error, even a Wi-Fi blip, was being read as "the TV forgot us" and the pairing thrown away. Now only a real certificate rejection counts. And some of my code-typing was self-inflicted: Claude's own debug builds on the Mac kept their keys in different places, so each one needed its own code.
The next TestFlight build had the same bug. I said so: "I downloded the most recet test flight and I'm still seeing it."
The real cause was in the reference library. androidtvremote2 uses the client name as the certificate's common name, so in the other project, "make the client name unique" also quietly made the certificate's subject unique. Ours was the constant "FreeRemote" on every install. The TV appears to keep one pairing per certificate subject, so a unique display name did nothing. The fix gives each install a certificate named "FreeRemote" plus eight random hex digits.
- Screens 2,048
- The four TVs 1,134
- TLS, certificates, protobuf 818
- Jellyfin 790
- App and settings 908
- Finding TVs 348
- Dev tools 257
On the TV: client A pairs, client B pairs, and A reconnects without a code. Claude tried to reproduce the old failure with two clients sharing a subject, set the test up wrong, and dropped it rather than spend more pairing prompts on my TV. So the cause is inferred, from my report, the fix working and what the reference library does. The lesson I'd keep is that a fix from someone else's bug report can work for a reason nobody wrote down. Read the implementation, not just the thread.
A camera across the room
Pairing needs someone to read a code off the TV, and I wasn't always there. Late in the day I pointed Claude at a skill for reading from a camera. The iPhone's frames came back black or blurry. The MacBook's camera, it turned out, faced the TV from across the room.
So Claude grabbed a full-resolution frame, read the code, and wrote it to a file the harness polls. That made the test that settled the pairing bug run on its own: pair, pair again, reconnect, read the next code. It also found that a client which disconnects mid-pairing leaves the code dialog on the TV. Claude cleared it by pressing Back from a client that was already paired, then took another frame to check the TV was home again.
Paw Patrol, now
We have small kids. Pinning Paw Patrol and Miffy so they play from our Jellyfin server in one tap was a real requirement, not a demo.
Jellyfin's server can tell a running client to play something. Three bug reports came back: with Jellyfin closed, the app spun forever; with it open, it worked; with an episode playing, the TV went to Jellyfin's home screen and never played anything.
The answers were in Jellyfin's Android TV app. It only listens for remote commands while it's in front. The server keeps listing its session for a while after it's gone to the background, so "a session exists" proves nothing. Relaunching it while it's playing drops it to its home screen. And our app had been guessing which session was the TV, a guess that failed as soon as other Jellyfin clients were around.
The fix matches the TV's session by IP address, launches the app only when nothing is playing, and repeats the play command every eight seconds until the server reports the right item playing, giving up with a clear message after 45.
Papercuts
The protocols took the first twenty minutes. Most of the rest of the day went to things that weren't the TV.
Claude can't click around App Store Connect from a terminal, so every step there was a prompt I pasted into Claude in Chrome. The first one failed because the bundle ID had never been registered: the build was signed with Xcode's wildcard profile, so Apple had no record of the app. Xcode's sign-in expired while I was away, and every upload failed with "Failed to Use Accounts". The test harness looked new to macOS after every rebuild, so the keychain asked for access each time until the harness was signed properly. One upload was lost because piping the release script through head killed the Mac export when the pipe closed. iCloud sync needs an entitlement, the entitlement needs a provisioning profile, and the profile didn't list this Mac, so local builds failed to launch with POSIX error 163. And every TestFlight build said 1.0, because the build number was bumped and the version never was. Releases now start at 1.1.0, and tagging one uploads it to TestFlight from CI. The first one went up on the first try.
None of that is about TVs. Most of what makes an app an app is the paperwork around it.
Three agents, one repo
For a while in the evening three agents worked in the repo at once. Claude, in the main session, built iCloud sync. A Claude sub-agent it forked built hardware volume buttons and live typing in its own worktree. Codex, separately, added app logos and more streaming shortcuts.
It was uneventful, which is worth a sentence. Main is locked: changes need a pull request, both platforms have to build, merges are squashed, and the rules apply to me too. Each task gets its own worktree, and notes attached to the code keep the non-obvious things, like the TV keying pairings by certificate subject. CI caught the one real collision: the sync change broke the harness, which compiles some of the app's files on its own, so the harness is built in CI now.
Not the TV
- Bundle ID never registered
- Xcode signed out: “Failed to Use Accounts”
- A keychain prompt on every rebuild
- An upload lost to | head
- POSIX error 163
- Every build said 1.0
Still to try
Some of this has only been tested on paper. The Roku and LG drivers follow their documented protocols and have never met real hardware. Wake-on-LAN for the Samsung Frame has only been checked against a local listener, and a Frame's power button toggles Art Mode, so turning it fully off takes a three-second hold, like the physical remote. On the first test, volume up and down didn't move the Sony's speaker level, probably because the sound goes to a soundbar.
If you have one of those TVs, try it and tell me. It's free, and it's going to stay free.
What does fast get us?
Notes from the first few days of playing with Jev, a model that answers instead of writes, around three ideas: compression, tagging and ranking.
Jev doesn't write anything. You give it some state and a list of narrow questions, and it gives back numbers. TypeSafe puts its response time at 70–500 ms, and most of my calls came back in under half a second.
I wanted to know what that speed is good for. If a judgment costs about as much as a network request, where does it go? I spent the first few days playing around, and the same three things kept turning up: compression, tagging and ranking.
Three ways to ask
Jev takes three kinds of question:
Yes or no, which the API calls noul, comes back as a probability that the answer is yes.
Choice picks one of the options you name, with odds for each and a confidence.
Score puts the answer on an ordered scale you write, like none, some, most, all. It returns a position between the ends.
Every answer is a number or a spread of numbers, never prose. That's the whole trick. The model makes the judgment, and plain code can sort, threshold, group and count it.
Tagging
A choice is a tag. In one demo, every post in a long discussion gets a role: proposal, objection, decision, reversal. Then you can lift the decisions out of the thread and see which objections nobody answered. In another, every commit in a history gets a category from its actual diff, not its message. When Jev isn't confident, the commit lands in needs review instead of a guess.
Ranking
A yes-or-no probability is a sort key. Semantic Search asks one question of each of 2,414 functions from Hono, TanStack Query and Vite: does this catch an error and carry on? The list reorders as each batch comes back. The first results showed up in about 300 ms, and the whole corpus in under nine seconds.
Score is the obvious ranking tool, and I barely needed it. A probability already sorts.
Compression
Compression is choosing what to keep. The context demo asks two questions about each turn of an agent's thread: is it relevant, and would dropping it lose something? It blends the answers, always keeps the two turns it can't do without, drops the ones with little signal, and fills a token budget from the top of the ranking down, skipping any turn too big for the room left. Choice compresses too. For each bug report, the duplicates demo picks the one passage that best explains how it relates to yours.
Pairs
The fun started when two of these met:
Tag and rank: review lenses tag each hunk of a diff by concern, with a probability. A threshold folds the quiet ones away.
Rank and compress: context selection ranks turns and then fills the budget from the top. A new budget only means choosing again from the same ranking, so it doesn't need a new call.
Tag and compress: for a changelog, Jev sorts the commits into categories, and a writing model gets the groups instead of the raw log.
None of this makes the model smarter. Fast just makes a judgment cheap enough to treat like any other value: compute it, show it, throw it away when its input changes, and ask again.
- backlog
- queued
- scouting
- in review
- ready to build
- building
- awaiting merge
- done
Tasks
Serial by Design
Tasks turns a GitHub issue tracker into merged pull requests. The first version ran its agents side by side and drowned in conflicts. The second sends everything through one long conversation, on purpose.
Tasks is a server that runs on my Mac. It reads a repository's GitHub issues, hands them to Claude Code agents working in throwaway VMs, and turns what they make into merged pull requests. Beside that pipeline sits an orchestrator: one Claude Code conversation that doesn't end. It reviews the agents' plans, starts builds, lands them and talks to me.
Over five months it collected 719 commits on main, 608 pull requests and 491 issues, and it's about 135,000 lines today. Before that, one commit deleted 81,716 lines. Most of what's interesting about Tasks happened between those two numbers.
N agents, N tasks
The first version, in March, had the obvious shape. Each task got a container, an agent implemented it directly, and the result went into a merge queue as a pull request. In sixteen days, agents opened 313 pull requests against Tasks' own repository, and 199 of them merged.
Parallel looked like leverage. A lot of it turned out to be cleanup:
The poller picked up agent pull requests as new tasks, which spawned agents that opened more pull requests.
Five containers at 8 GB each could exhaust the Mac's memory and lock it up. That happened twice.
A detector that watched agent output for questions like "should I…" was switched off for false positives.
One commit is titled "Fix review issues from bulk-merged PRs".
- backlog
- queued
- scouting
- in review
- ready to build
- building
- awaiting merge
- done
In April, an RFC named the problem. N agents implementing N tasks in parallel leads to "endless conflicts", "duplicate work" and "no shared context". Each agent could be right on its own terms and still be wrong next to the others, because none of them knew what the rest were doing.
Version two began with a commit that "deletes all v1 code, docs, web frontend, desktop app, CI, and scripts". That's the 81,716 lines. Then the branch sat for sixteen weeks. Work picked up again on August 9, and most of what follows was built in the twelve days after that.
Explore in parallel, build in series
The RFC's answer was to split the work in two and give each half the opposite kind of concurrency.
Exploring is parallel. A Scout takes one issue into a fresh VM and implements it for real. Then its code is thrown away. What the Scout hands back is a SPEC.md, a written description of the change. The RFC's case for doing the work twice: "Throwaway implementation forces real understanding. An agent that just reads an issue and writes a spec is guessing."
Building is serial. Once a spec is approved, a Builder implements it in another fresh VM, without ever seeing the Scout's code. There's exactly one build lane. Approved specs wait for it, and when it frees up, whatever is waiting goes in together as one branch and one pull request.
Two ideas hold this up. The first is the barrier: "Reimplementing from the spec alone is what proves the spec is complete." If the Builder can't get there from the text, the text was missing something. The second is that text composes and branches don't. The resume plan is blunt about it: "at 70+ issues no tool keeps N branches conflict-free, but N documents merge trivially."
There's a small rule hiding in the Scout. It keeps NOTES.md as it goes, saved every 30 seconds, and writes SPEC.md only once it has actually concluded. If it dies early, the notes survive, but they never become a spec, because "a half-explored spec in the review queue looks finished." A valid SPEC.md finishes a Scout whatever its exit code. A missing one is the failure.
The single lane does a quieter job too. Adding a repository with 11,000 open issues must not turn into 11,000 Scout runs. Intake drops everything into a backlog that nothing is dispatched from, and since builds are serial, "the 11,000-PR outcome is structurally impossible rather than rate-limited."
One long conversation
The orchestrator is the part I find most interesting.
It isn't an agent framework. Every second, the server checks whether anything is waiting for it. If something is, it shells out to headless Claude Code and resumes the same session it resumed last time: one conversation, days long, picking up where it left off. By default the only tool it has is curl, pointed at Tasks' own HTTP API.
- backlog
- queued
- scouting
- in review
- ready to build
- building
- awaiting merge
- done
It's serial on purpose. The plan that defines it says:
Accumulated context is the product, not an implementation detail. … without it, the platform is N parallel Claude Code sessions with extra steps.
The evidence came from review. Of three real defects the orchestrator's review caught, two could only be caught by something holding the other in-flight work in mind. One spec added migration 0009 when a 0009 had already landed on main. Another rebuilt a test harness that was sitting in an open pull request. A reviewer that sees one spec at a time can't see either, so parallel verdicts were ruled out. The orchestrator is "the pipeline's serial bottleneck by design."
Each turn's prompt is built from four voices:
me
[pipeline] notices about what just happened
[agent] messages from outside agents, read "as a peer's unverified leads"
[worker] reports from the disposable sessions it sends off to do labor
Notices are batched and can be dropped, and that's fine, because they only make it faster. What can't be dropped is computed instead. Every minute, the server works out what the orchestrator owes, like a spec to review, a build to dispatch or a batch to land, and keeps reminding it until it's done. The rule behind that is one of the best lines in the repo: "every state the pipeline can rest in either is terminal or has an obligation that names who owes what. A state that is neither is a place work goes to be forgotten."
The server also precomputes a brief for each decision: files a spec touches that open work also touches, clashing sequence numbers, the state of related pull requests, earlier verdicts. It replaced a prompt that spent 10–50k tokens a cycle paging through the event log to rebuild the same picture. The plan calls one property of the brief load-bearing, and it's good advice for anything that summarizes for a model: "silence means unchecked, not fine."
A long conversation has its own way of failing. If a resume fails, the chat carries on as if nothing happened, and "the thing writing it has forgotten the morning." Now a failed resume closes the old session in a ledger and writes a visible seam into the conversation.
Authority lives in a table
What the orchestrator may do isn't written in its prompt. It's in a table.
The charter has one row per capability, eleven in the code, from queue_tasks and auto_review_specs to land_builds and dispatch_workers. Each is set to off, shadow or live, and only I can write the rows. Every turn, the authority section of the prompt is generated from them, and the server enforces the same rows on its endpoints. A comment in the orchestrator explains why: "A prompt sentence is the weakest mechanism this codebase has — the charter exists because authority should not be something a long conversation can talk itself out of."
- backlog
- queued
- scouting
- in review
- ready to build
- building
- awaiting merge
- done
The interesting part is the defaults. Every capability shipped live. The rejected alternatives are written down: everything off ("wrong safe"), shadow first, daily caps, and a "the human told me to" flag, which "would hand the charter's keys to the thing it governs." Instead of approval gates, every write lands in an append-only decisions ledger to be read afterwards. The README's version: "Oversight happens after the fact, where it's cheap, instead of as pre-approval gates, where it's the bottleneck."
Shadow mode got one real day. With auto_review_specs in shadow, the orchestrator "wrote a correct and well-argued verdict — and then handed it back as prose for the human to read and re-enter by hand." That's the most expensive setting there is for the one resource that's actually short. The same plan says plainly that tokens aren't the constraint: "the scarce resource is the human's attention, not tokens." Shadow is now only for demoting a capability that misbehaves.
The model has a sharp edge, and the code says so. A write the server can't attribute is recorded as mine, and I'm never gated, so a broken agent credential "does not fail closed, it escalates." That's why workers get no curl at all. A comment on the issue that proposed workers, opening with "Orchestrator here.", spelled out what would happen otherwise: a worker with curl and no credential has "full human authority over the entire API."
Confident and wrong
Give an agent authority and it will sometimes use it wrongly, with complete confidence.
The orchestrator once rewrote a Builder's pull request body from "Implements #N" to "Closes #N" and reported it as a bug fix. The original wording was deliberate. The server neutralizes closing keywords so GitHub won't close an issue just because a pull request merged. The issue that recorded this says: "The orchestrator was confident and wrong, and nothing in the system would have caught it." It had also been counting opened pull requests as shipped work.
That produced a rule that sounds obvious and took two tries: done means shipped. The second try came from #863, a pull request GitHub shows as merged whose work never reached main. It was stacked on another branch and merged into that branch after the branch had already landed. The work was recovered from refs/pull/863/head. Now a task is done only when its pull request is merged and the merge commit is an ancestor of trunk, and only that check closes the issue.
Continued from page 38 Serial by Design
A laptop is not a server
Tasks runs on a Mac, and Macs sleep.
In #929, a build went out at 03:44 UTC. The lid opened at 12:34, and the build was declared timed out against its 3,600-second budget. It had held the only build lane for nearly nine hours and charged each of three specs a strike for a closed lid. Deadlines now run on two clocks, wall time and awake time. The first fix waived the strike for any sleep over 60 seconds, which meant "a 61-second nap waives the strike for a build that timed out with the lid open," so it got split again.
Under that is the strike rule: a strike is charged for a verdict, and only a verdict. Three strikes reject a task. Infrastructure kept charging them anyway. Agent processes died around 380 seconds in, when the VM's connection dropped mid-response. GitHub had outages. The container runtime stopped. Each became a hold or a retry instead, and agents now resume in the same VM, up to twice, after a dropped connection.
Some lessons were about deploying more than code:
Supervisor fixes were "inert until make images", and for a while every image build failed on a dead redirector, so none had reached a VM.
cargo clean deleted the running system, because "the system's stable home was a build cache."
Any web page could post to the local API and dispatch builds, until the server learned to refuse it.
A log line printed the agent's API key on every VM allocation. Fixing the log didn't fix the copies ("Redaction is a claim about future writes"), so keys came out of the VMs entirely. Agents now get scoped, expiring leases redeemed at a broker.
It built itself
Through August, Tasks was mostly building Tasks. The Builder opened 91 pull requests, and 81 merged. The pipeline's own identity, Tasks Builder, has 254 commits across branches. When a Builder left work uncommitted, a server step committed it anyway, as "Sweep: work the agent left uncommitted."
Self-hosting had a cost that's easy to miss. CLAUDE.md, the file every agent reads, went from 8,823 characters to 190,413 in seven days, the same seven days the pipeline was building its own repository. That's about 48k tokens on every agent turn. Each lesson had become a rule, and every rule was read every time. It was cut back, and a test now fails if the file passes 20,000 characters. The rule that replaced the sprawl: CLAUDE.md holds universal rules, and the code holds its own reasoning.
That reasoning has a house style. Doc comments and commit messages open with the failure that produced the design, so you could rebuild most of this piece from the source. The comment above the deadline code tells you about the lid. The one above the orchestrator's environment notes tells you about the time an orchestrator was told it had a checkout, "spent a turn reaching for python3, Write and a heredoc, had all three denied, and reported the denials as a tooling failure. It was right; the prompt was lying to it."
Where it is
The last commit is from August 21. In September, the issues show Tasks pointed at a second repository, a Svelte app, and finding what a second project finds. The worker lane assumed the Tasks checkout, so four survey workers came back with nothing, and one worker dispatch fired twice inside 79 milliseconds. Older problems are still open too, like a build that implemented two of its four specs, exited cleanly and was recorded as succeeded, so merging it would close untouched issues as done. The README opens with a section called "Read this first": the agents run with their permission checks off, the server acts unattended, and the local API has no authentication. It's "software one person wrote to run on his own machine."
The shape that survived is simple to say. Explore side by side, because exploring is cheap and agents are good at it. Decide in one place, because the decisions that matter are the ones that need to see everything else in flight. Build in single file. Check afterwards, because checking first is where the human's attention goes to wait. The parallel part is easy. The serial part is where context builds up, and context, as the plan says, is the product.
Telephone
Do Not Acknowledge Acknowledgments
Call ticket No. 0914
Telephone lets Claude Code and Codex sessions on the same machine find each other, pass notes and wait for replies. Most of the work went into being honest about what it can't promise.
From codex:… To claude:…
Telephone is a command-line tool and MCP server for sending messages between coding agents on the same machine. Right now that means Codex and Claude Code. The README opens with a warning I meant: unsafe, experimental software. If one agent sends another a message, that can lead to commands being run or files being changed.
It started on September 14 with a question about what an agent-agnostic communication layer between agents would look like. After one round of design I told the agent to stop: "just go build it and we'll try it out rather than going back and forth on a design." The first commit landed that morning, and the first release went out at lunch.
The use I had in mind is the example on the homepage. You've got Claude Code working on a plan in one terminal and Codex in another, and you tell Codex: find the Claude session working in this project, read its latest plan, check it against the code, send feedback, ask for a reply. Telephone handles discovery and delivery. The agents do the reading.
Three tools
The interface is small on purpose. Over MCP there are three tools: list_agents, send_message and check_inbox. The CLI mirrors them (telephone list, send, inbox), adds whoami and doctor, and telephone install prints the config for both runtimes. It doesn't edit anyone's config files itself.
Addresses look like runtime:local-id, so claude:24207 or codex: followed by a thread UUID. There's no registration step. Messages come in kinds: inform by default, request when you need an answer, and reply with a reply_to ID. Every message is an envelope with an ID, a conversation, sender and recipient, a body capped at 64 KiB, a trust field that can only say untrusted, and a hop chain. After eight hops the conversation stops, and the error says to start a new one only with user direction.
Two runtimes, two very different phones
Neither runtime documents a way for outsiders to talk to it, so I had to learn how each one works from what's on disk.
Claude Code turned out to be the easy one. It already has a peer-messaging layer. I found it by poking around, not in any docs. Each live session writes a JSON record to ~/.claude/sessions/<pid>.json, there's a private 0600 token file next to it, and there's a Unix socket that takes newline-delimited JSON. The first frame authenticates and the rest are messages. Telephone speaks that directly, so a message turns up in Claude's transcript as a new user turn. Since none of it is documented, any Claude Code release could break it.
Codex has no socket. Discovery reads Codex's own thread registry, a SQLite table with each thread's ID, working directory, nickname and last update. Delivery goes through codex queue --thread <id>, which puts a message in a thread's queue, and Codex picks it up when the thread next runs. That's a real push channel, but all you learn is that the message was queued, not that anyone saw it.
That split shows up all over the tool. In telephone list, a Claude session gets the label live, meaning the process and its start time were verified. A Codex thread gets recent?, question mark included, because recent activity can't tell you whether a thread is still running or exited a second after its last write.
I tried to do better and couldn't. Only the Codex process that loaded a thread knows its status, and a freshly started app-server answers notLoaded for every thread on the machine. The ChatGPT app's private socket frames messages with a four-byte length, so a request that opens with {"id reads as a length of 1,684,611,707 and gets hung up on. Even spoken correctly, it answers who owns a thread, not whether it's running. About 200 lines of liveness code came out in a commit titled "Remove Codex liveness enrichment that could never confirm anything." I'd rather show the question mark than fake an answer.
The first call
That afternoon, a Codex session sent a request to a Claude Code session named telephone-demo. It arrived through Claude's socket as a new turn. Claude checked its own identity with list_agents and replied with the request's ID. The reply went into Codex's queue and showed up as a new Codex turn once the original turn finished. The nonce was switchboard-914-r2. Claude's reply:
Verified: I am claude:24207. Nonce: switchboard-914-r2. Two agents traded careful signals and finished together.
The demo also found two bugs. A sandboxed process probe was hiding live Claude sessions. And Claude's MCP process had inherited the Codex thread ID from whatever launched it, so its first reply was rejected as a self-send. Both are fixed.
A second opinion
Claude wrote the first version. Then I had a Codex session review it, and it came back with four high-priority bugs and seven medium ones, each with a reproduction. The suggested reply command dropped the sender's address in unquoted, so a test fixture could run a stray printf. Every send started a fresh hop chain, replies included, so the eight-hop guard could never trip. Two readers draining one inbox at the same time could hand out every message twice. And "delivered" only meant a socket write had happened.
My instructions for the fixes were to harden the Rust, be skeptical of ?, avoid mocks, and "treat security as if we will enable messaging from other sources in the future." Inbox reads became SQLite transactions, marked read only after the response is written. A journal keeps each reply's ancestry, so hops add up across a conversation. And a native send that fails after it may have started is reported as uncertain and never retried.
Trust, or the lack of it
Everything here assumes one machine and one OS user. Addresses and names are routing hints, not identities, and nothing authenticates them. All peer text arrives labelled as untrusted and quoted as a JSON string, so it can't fake a header or a second reply command. That isn't a prompt-injection sandbox, and a comment in the code says so. The receiving agent still follows its own user's instructions and approval rules. A peer can't grant authority. It's another agent with opinions, not a user.
Identity gets the same treatment. Codex's MCP identity comes from the per-call threadId the host sends. Claude's comes from walking up the process tree to a verified session. A working directory, a display name or "the newest session" is never used to guess who you are. If identity is ambiguous, Telephone fails instead of picking one.
Accepted, not read
The hardest problem was replies, and I found it by using the thing. One night in September I left a three-agent team building Shelf Goblin, my audiobook catalog: a Codex session as lead and two Claude sessions taking tasks from it over Telephone. One Claude sent the lead four coordination messages over about 40 minutes. Each came back Accepted by codex queue; not a read receipt, which was true. The lead was polling its Telephone inbox, which was empty, and it answered through me:
Continued from page 46 Do Not Acknowledge Acknowledgments
My inbox is still empty despite Nate saying you need me. If telephone delivery is broken, please put your coordination note at …HANDOFF.md (gitignored), and tell Nate it is there.
Nothing was broken. A Codex queue message can wait until the thread's current turn ends, and a lead that's polling for replies is in the middle of a turn. The agents worked around it by writing handoff files into the repo, and the next day the sending Claude wrote it up as issue #42, with the outcome of every message in a table.
Fifteen seconds
Version 0.2 sorts this out with evidence that runs out. Each inbox check advertises that the agent is polling for 15 seconds. Sending a request does the same for the sender, so a quick reply can land before its first poll. Claude's evidence is tied to the session, not just the PID, so a new session can't inherit an old one's window. When Telephone sends a new message, it picks a route in order:
If the sender asked for the inbox explicitly, use the inbox.
If the recipient has fresh polling evidence, use the inbox.
Otherwise go native: Claude's socket, or Codex's queue.
A native route can fall back to the inbox, but only before any bytes have gone out. Once a native send may have started, Telephone won't retry it or copy it into the inbox. When the evidence expires, routing changes for new messages only. Nothing that was already sent gets moved or resent.
Acceptance is not a read receipt. A Claude socket write is unconfirmed. A Codex queue command that succeeds means accepted, not read. The inbox can't wake an idle agent. Send results now say which channel a message took, an empty Codex inbox mentions messages accepted by its native queue, and telephone doctor <address> shows the routing evidence and the last 20 journal entries to that recipient, without the message bodies.
Narrowing the line
For a few days in mid-September, Telephone also tried to cover OpenCode, Zed and Delta through registered inboxes and HTTP bindings. Then I asked whether dropping everything but Codex and Claude Code would make it easier to use. It would. In 0.2.0, on September 20, I took all of that out. The agent started writing a migration path and I stopped it: "basically no one uses this yet." It's a breaking change with no compatibility layer, and the source got about 1,300 lines shorter.
Telephone is back to the two runtimes I could actually test against real sessions. The release suite ran Codex↔Claude, Codex↔Codex and Claude↔Claude exchanges with real models on macOS ARM64, including waiting out a real fifteen-second expiry. Long conversations between agents eat context fast. Two overnight Claude sessions had reached 415,632 and 505,465 tokens, so there's now an optional Claude hook that reminds the agent to compact at 250k and again at 300k.
There's real work left, and it's listed in the security doc: authenticated senders and per-agent authorization, confirmed receipts and safe retries, retention and rate limits. Until the first one exists, Telephone shouldn't be reachable from a network.
It's a small tool. Being honest about what it doesn't know turned out to be most of the work. My favorite line is still the last one in the agent instructions: request a reply only when you need one, and do not acknowledge acknowledgments.
Spool
A Lease on the Work
Spool keeps a repository's tasks in the repository, as a log of events. I built it in January, stopped using it, and in September had Codex rebuild it in an afternoon around one question: which agent has this task right now?
Spool is a task tracker that lives in the repository it tracks. There's no server and no web app. Tasks, notes and completions are events, written as small JSON files under .spool/ and committed with the code. A command line tool reads and writes them, and a terminal UI shows them.
It was built for coding agents. Several agents can work from one board: each takes a task, keeps notes on it, and finishes it or hands it on, and none of them can take a task another one is holding.
That last part is new. The first Spool, in January, was a tracker that agents could use. In September I rebuilt it so that several agents could use it at once, and most of what's interesting is the gap between the two.
cargo install spool-cli --locked
spool init
spool add 'Fix the thing' -p p0 -t bugTasks in the repo
Tasks should live where the code lives. When project tracking is a SaaS product, you're one acquisition or pricing change away from losing your history. Worse, the tracking is cut off from the work: tasks sit in a web app while the context is in the repo, the branches and the commits. In Spool the history travels with the code. Clone the repo and you have it.
Spool came out of exploring agentic orchestration. In January I spent about two weeks running several projects through Gas Town, a multi-agent setup where a coordinating agent files work for a pool of workers. Its issues are Beads, another tracker for agents. Tools like that exist, but I wanted something more opinionated and more mine: build it, use it every day, see what works.
The spec that started it, on January 13, was called Fabric. It asked for a tracker that was git-native, conflict-resistant, auditable, and "agent-scale," handling 50 to 200 tasks a day from automated agents. Fittingly, the first day's code was written by Gas Town's worker agents, from epics in Beads. The commit authors are named eventsmith and rustsmith. Three days later Fabric was renamed Spool, and it reached 1.0 on January 24.
An event log
Every change is an event appended to a log: create, update, assign, comment, link, complete, reopen. These two are from Spool's own board, trimmed:
{"v":1,"op":"create","id":"mu31qv7e-dq90","ts":"2026-09-15T19:10:11.855819Z","by":"codex:spool-reforge","branch":"codex/agent-coordination","d":{"title":"Remove the standalone coordination design document","priority":"p2"}}
{"v":1,"op":"complete","id":"mu31qv7e-dq90","ts":"2026-09-15T19:11:06.062398Z","by":"codex:spool-reforge","branch":"codex/agent-coordination","d":{"resolution":"done","ref":"README.md"}}Each event says who did what, when, and on which branch. The current state of every task is whatever you get by replaying the events in order. Task IDs are a base-36 timestamp plus four random characters, so two branches can create tasks without coordinating.
The idea held up. The event envelope from the January 13 spec is the one Spool reads today; the schema's first version is frozen, and the rebuild only added new operations.
What I had wrong
The January version of this post said conflicts would be rare, because two people appending to the same daily log just meant keeping both sets of lines. That's true of the events. It isn't true of Git. Two branches that each add a line to the end of the same file have made two edits in the same place, and Git stops the merge with conflict markers. Resolving it by hand was easy, but it wasn't automatic.
When I came back to Spool, the agent I asked to audit it found more:
spool claim didn't claim anything. It assigned the task to the Git user, unconditionally. Every agent on my machine was me, so a claim couldn't tell two agents apart, and nothing stopped a second one claiming the same task. On the board for Luna, my design tool, all 226 January events are by @iamnbutler.
The code had a concurrency module, but ordinary writes didn't use it.
Reads could trust a cached snapshot, .state.json, long after the events had changed. The fix in January's README was a post-merge hook that ran spool rebuild.
Each Git worktree found its own .spool folder, so agents in two worktrees had two boards and couldn't see each other's work.
None of that bit hard while I was the only one using it, one agent at a time. It would all have bitten the moment several agents shared a board, which was the point.
Dropped, then picked up
The January post ended on two open questions. Agents wouldn't use the tracker consistently: they'd track tasks for a while, then forget to update status, create duplicates, or ignore the backlog. And the hooks that kept caches fresh were local and fragile, fine for one person but not for a team.
Then I stopped using it. The last feature work landed on February 14, with live reload in the TUI. Through early March an automated repo assistant kept adding tests and updating dependencies, and after that the repository sat for six months.
On September 14 I started Telephone, a small tool for passing messages between Claude Code and Codex sessions on one machine. The next afternoon I gave Codex this prompt:
so, we sort of explored spool for a while then dropped it. I recently built https://nate.rip/telephone/ and thought, "I wonder if we can pair spool and telephone to make an effiecient system for agents to collaborate together" why don't you investigate the current state of spool and pull out a branch and go take a chainsaw to it, reforging it into a really solid tool for agents to use for task management.
That was at 13:57. It tracked its own work in Spool as it went, in a stream called agent-coordination. The pull request added 3,794 lines and removed 2,133. It merged at 15:15, and version 2.0.0 was published at 15:38.
One board per repository
The first decision was where the board lives. Worktrees are how agents get to work side by side in one repository, so the board couldn't belong to a worktree. It lives in the repository's Git common directory, .git/spool, which every linked worktree shares, including ones made before spool init. Switching branches doesn't roll it back.
That splits Spool into two places. The live board in .git/spool is where work happens. The .spool/ folder in a checkout is what Git carries. spool sync copies durable events from the board into the checkout so they can be committed, including events recorded in other worktrees. Each command also imports whatever events it finds in its checkout, so history that arrives by pull or merge joins the board. Sync never commits, pushes or merges.
Some events never leave the board. Claims and renewals are written to .git/spool/.local/events and are never exported. A clone starts with the task history, then makes its own claims. So the boundary is one machine. The README says it in bold: independent clones or machines do not share atomic claims. Git is how history moves between machines, not how agents on different machines avoid each other.
Continued from page 54 A Lease on the Work
Claims are leases
Version 2 changed what a claim is.
Every agent session needs its own identity, set with --agent, SPOOL_AGENT or a Telephone address. The Git user is still fine for ordinary edits by a person, but claiming work requires an explicit identity.
spool next picks the highest-priority ready task and claims it in one step. Picking and claiming as two commands would leave a gap where two agents pick the same task. When there's no work, next returns null and exits 0, because an empty queue isn't an error.
A claim is a lease. It lasts 15 minutes by default, anywhere from a second to 24 hours on request, and the agent renews it while it works. If an agent crashes or wanders off, the lease runs out and the task goes back in the queue. That's the answer to January's agents that forgot to update status: a forgotten task now comes back on its own.
Every claim comes with a fresh random token, and changing a claimed task needs both the token and the matching identity. That fences off stale workers. If agent A stalls past its lease and agent B claims the task, A's late complete fails, even if A is using the same name, because the new claim has a new token. Anyone can still add notes to a claimed task. And an expired claim can be released by anyone, without a token, with a note saying what happened, which matters for a task whose prerequisites keep it from being claimed again.
Everything is shaped for an agent reading the output. Every command takes --json and writes one JSON value. Errors are JSON on stderr with a stable code like claim_conflict or lease_expired. Exit code 2 means a conflict, a stale claim or a busy board, so an agent can tell "try again" from "something is broken."
The test that matters most starts sixteen real processes and has them all claim one task at the same moment. Exactly one wins. Another runs next from sixteen processes against a board of eight tasks: each task is handed out once, and eight agents get null.
Events as files
The storage changed underneath.
Each new event is its own file now, named by its timestamp and the SHA-256 hash of its contents. Two branches that each add events add different files, so Git has nothing to merge. Copying an event twice is harmless, because the second copy has the same name and the same bytes. A file whose name doesn't match its contents is treated as tampered with, and Spool refuses to read the board. The old daily logs and monthly archives are still read, and never rewritten.
Writes go through one lock per board, an operating-system file lock that the kernel releases when a process dies, so a crash can't wedge the board. Under the lock, a write replays the current events, checks the change against the result, and publishes it whole: written to a temporary file, synced, then moved into place without overwriting anything. If another agent got there first, the check runs against its result. If a timestamp would land at or before the last event's, it's nudged a nanosecond past it, so the order of events always matches the order of writes.
Reads always replay the events. No read trusts a cache, so there's nothing to go stale and no hook to keep fresh. January's question about hooks didn't get a better hook. It went away.
Streams and dependencies
Streams group tasks into a project or an epic. The agent guide asks for scope, acceptance criteria, file ownership and expected output in each task's description, because the description is what the next agent reads.
Tasks can block each other. A task is ready when it's open, nobody holds a live claim on it, its prerequisites are complete, and any reservation on it matches the agent asking. "Ready" depends on who's asking. A missing prerequisite blocks too. Spool checks for cycles when an edge is added, under the same lock, so two agents adding opposite edges at once can't make one. Completing a prerequisite makes its dependents ready; it never completes them.
There's a difference between assign and claim. Assigning reserves future work for someone. Claiming says who's working on it now.
Spool and Telephone
Spool keeps tasks. Telephone carries messages. Neither runs the other.
spool handoff records who the task is for, a note and a reference together, releases the current claim, and returns a message ready to send with Telephone. Spool never sends it. The recipient still has to claim the task before working on it, because, as the agent guide puts it, "a message or assignment is not proof that work has started." If a message is late or lost, the assignment and the note are still on the board. Handoffs work the same for a person or an agent without a Telephone address.
Continued from page 56 A Lease on the Work
How it's used
A new agent starts with spool prime, which prints the whole work loop in under forty lines. The repository's skills/spool.md is the same loop as a skill, and the project's AGENTS.md points at both. The loop:
export SPOOL_AGENT=parser-worker
spool next --json
spool renew <id> --token <token>
spool comment <id> 'Parser done; checking Unicode boundaries' --ref src/parser.rs
spool complete <id> --token <token> --note 'Implemented; parser tests pass'
spool syncA person mostly plans: makes a stream, adds tasks with good descriptions, and says which block which. Then the agents pull.
The day after the release, four agent sessions, two Claude Code and two Codex, built the first features of a personal assistant app from one Spool board, with Telephone between them. In two hours they made 12 tasks in two streams and completed all 12. There were 12 claims and 11 renewals. The agents asked for leases of an hour or two rather than the 15-minute default. Nobody used handoff. They talked over Telephone and wrote it down in notes: 37 of them, half longer than 780 characters, the longest about 8,000.
The coordinating agent wrote a retrospective afterwards. Telephone was the main source of friction; its messages could be accepted without reaching the session that needed them, and it had to be patched mid-run. Spool needed no changes: "Claims and leases made ownership explicit; descriptions recorded acceptance criteria and file boundaries; completion notes recorded actual checks." And one line I'd keep: "Spool did not replace conversation, but it was a useful record of what that conversation had decided."
The TUI
spool-ui is a terminal view of the board: a priority-sorted task list, a detail pane with each task's full event history, search, and views for streams and for the whole project's history, newest first. It reloads when the events change. Since version 2 it shows which agent holds a task and when the lease runs out, and it goes through the same guarded writes as the CLI, so it can't overwrite a live claim. It still can't renew or complete one; that takes the CLI and the token.
What I learned
Getting agents to use a tracker consistently was the main problem in January, and it's a lot like getting people to. Discipline is hard enough for humans. Version 2 doesn't make agents disciplined. It makes forgetting cheap: a lease runs out, a token goes stale, and the task goes back to whoever's next.
"Claim" meant the wrong thing for months without anyone noticing, because one agent at a time can't see the difference between an assignment and a lock. The first real test of a coordination tool is several agents racing it, and that's the test Spool now runs in CI.
The hard part wasn't the log. Append-only events were right on the first day, and they're why history from January still loads. What needed rethinking was everything around the log: where the board lives, who's allowed to write, and what a read can trust.
Where it is
This site used Spool for its own tasks; the January post said so. The .spool/ folder went when the site moved to Cloudflare in October. I'm not using it day to day now. We might pick it back up, for real or in spirit: what I'd want is a local, lightweight replacement for GitHub, and that would need to be a good deal more full-featured than Spool is.
The boundary is written down. Atomic claims cover one repository and its worktrees on one machine. Older Spool binaries don't take part in the lock, so every agent on a board has to run 2.0. It's open source and on crates.io, and it's small enough to read in an afternoon, which is about how long the rebuild took.
Types Worth Keeping
Ferrotype
Ferrotype derives TypeScript types from Rust types. The bar was types you'd be happy to have written by hand. It took three days, a swarm of agents, and one rewrite from scratch.
When a Rust service talks to a TypeScript client, I want the client's types generated from the server's, so the two can't drift apart. There are crates for this, but I couldn't find one that wrote good types. They compile, but they use hardly any of what makes TypeScript good, and you wouldn't want to have written them by hand.
The bar I had in mind was a types package in ace, the collaborative coding app I was working on at GitHub Next. It was written by an engineer who contributes to Svelte's core, and it uses the language well: discriminated unions with a rich type tag, template literal IDs like vm-${string} , intersections like Claude.Todo & { createdAt: UNIX }, nested names like VM.Git.State, and indexed access to keep one type's field in step with another's. I wanted a Rust derive to produce types like those.
Ferrotype is that derive. It's published as ferro-type, because someone already had ferrotype on crates.io.
It started as RPC
It didn't start as a type generator. On January 9 I asked for an RPC library like tRPC, with a Rust server and a TypeScript client. The brief was that the Rust side "generates sophisticated, modern types, with proper unions, no use of as, any, etc." and to "focus on correctness of types over codebase simplicity."
By that evening it was clear the RPC wasn't the hard part. As I put it then, "the type gen and validation of the type gen is most of the work, and useful beyond the rpc bit." I often want Rust services that talk to TypeScript without RPC at all. So the plan changed: ferrotype would be type generation only, and an RPC layer could come later as its own crate. It never did.
The other decision that night was to start over. serde was the model, and rather than refactor the RPC-era code toward it, the core was rewritten from scratch, "so we aren't trying to both do api design AND keep all the things we've built so far working at the same time." The RPC traits and tests were deleted two days later, once the new core could stand on its own.
A tree, not a string
The foundation is one trait, modeled on serde's Serialize:
pub trait TS {
fn typescript() -> TypeDef;
}TypeDef is an enum that describes a TypeScript type: primitives, arrays, tuples, objects, unions, intersections, records, literals, template literals, indexed access, references, and named types that carry a namespace and the Rust module they came from. Nothing becomes a string until the end. A derived type comes back as a named node wrapping its definition, so a registry can collect every named type once, however many places refer to it, and render a file.
#[derive(TS)] writes that impl for you. Here's an enum from the test suite and the TypeScript it renders, exactly as the test asserts it:
#[derive(TS)]
enum Message {
Ping,
Text(String),
Binary(Vec<u8>),
Error { code: i32, message: String },
}{ type: "Ping" } | { type: "Text"; value: string } | { type: "Binary"; value: number[] } | { type: "Error"; code: number; message: string }Every variant carries its tag, so TypeScript narrows on type in a switch without a cast. An enum with only unit variants becomes a union of strings instead, like "Pending" | "Active" | "Completed". Option<T> becomes T | null, Vec<T> an array, HashMap a Record, tuples tuples, and i128 and u128 become bigint. Result<T, E> becomes { ok: true; value: T } | { ok: false; error: E }.
Registration had to be automatic. Early on, using the registry meant calling registry.register::<T>() once per type, and I didn't want that: "There could be hundreds of structs." Now the derive also adds each type to a linkme distributed slice, which the linker assembles at build time, and TypeRegistry::from_distributed() picks them all up. linkme won over inventory because it costs nothing at runtime and doesn't depend on code running before main. A second crate, ferro-type-gen, writes the result to a file, adds export, can write one file per Rust module, and skips the write when nothing changed, which keeps build.rs from triggering rebuilds.
serde's vocabulary
The attributes follow serde's names, so anyone who knows serde can read them: rename, rename_all, skip, flatten, transparent, and for enums tag, content and untagged. An adjacently tagged enum from the tests:
#[derive(TS)]
#[ts(tag = "tag", content = "data")]
enum AdjacentStructVariant {
Error { code: i32, message: String },
Info { text: String },
}{ tag: "Error"; data: { code: number; message: string } } | { tag: "Info"; data: { text: string } }There are two kinds of optional, because TypeScript has two. A plain Option<T> is field: T | null: the key is always there and may be null. #[ts(optional)] makes it field?: T: the key may be missing.
The good parts of TypeScript
Then the features from ace's types. A template literal pattern turns a string into a checked shape:
#[derive(TS)]
#[ts(pattern = "vm-${string}")]
struct VmId(String);type VmId = `vm-${string}`;
TypeScript will now reject "abc123" where a VmId belongs. #[ts(namespace = "VM::Git")] puts a type inside nested namespace blocks and references it as VM.Git.GitState. #[ts(extends = "Claude.Todo")] makes an intersection, Claude.Todo & { … }, with a type that lives only on the TypeScript side. #[ts(wrapper = "Prettify")] wraps a definition in the helper that's in almost every TypeScript codebase, type Prettify<T> = { [K in keyof T]: T[K] } & {}, and the generator can emit Prettify itself so the file stands alone.
Indexed access goes furthest. #[ts(index = Profile, key = login)] renders a field as Profile["login"], so it follows whatever type login has. Rust has no indexed types, but a derive macro can still check one. As I said at the time, "we are in a derive macro... magic can happen here." The derive writes a small function that reads login from a &Profile, and if the field isn't there, the build fails:
error[E0609]: no field `nonexistent` on type `&Profile`
--> tests/compile_fail/indexed_access_invalid_field.rs:13:33
|
13 | #[ts(index = Profile, key = nonexistent)]
| ^^^^^^^^^^^ unknown field
|
= note: available fields are: `login`, `email`That's a broken TypeScript reference caught by cargo build, before any TypeScript exists. With string arguments instead, index = "ExternalType", it points at types Rust can't see and skips the check.
Continued from page 64 Types Worth Keeping
Built by a swarm
Almost none of this was typed by me. I ran it through Gas Town, a multi-agent setup in which a coordinating "mayor" session files tasks, a pool of worker agents picks them up, and a merge queue lands their branches. The commit log reads like a cast list: furiosa, nux, slit, dementus, rictus, toast. There are 78 commits, 39 of them on January 11 alone.
That Sunday covered serde parity, the README, the crates.io release, auto-registration, the generator crate, template literals, namespaces, intersections, indexed access, Prettify, and renaming the derive from TypeScript to TS. The crates went from 0.1.0 to 0.2.0 in five releases over about nine hours. crates.io won't accept a version twice, so every release meant bumping all three to the same number. A script does that now. Rust 2024 made gen a keyword, which broke every example that named its generator gen. The root README still described the crate as an RPC library for a while after the rewrite.
The swarm took watching. A worker renamed typescript_type() to typescript() and pushed with the fixtures crate still calling the old name, and it took two warnings, the second headed "CRITICAL: Tests failing!", to get it fixed. Merged work often never reached GitHub. On the morning of the 11th, the newest commit on main was 23 hours old after a full day of work. The first hello-world example only simulated its server's responses. For an example of an RPC server, that's "worse than useless", and it runs a real Axum server now.
On January 12 I moved the swarm to other work, and ferrotype was paused.
Using it
I tried it first in ace on January 11, the afternoon ferro-type-gen went up, exporting ace's API types to a file with collect_all(). The problems it turned up were fixed in 0.1.2 that evening. ferro-type never landed in ace, though: its Tauri desktop client already used tauri-specta for typed commands, and kept it.
The longer use came in February, in agent-foundry, a Rust agent of mine with a Svelte UI. Nineteen request, response and event types derive TS. A --gen-types flag writes them to ui/src/generated/types.ts, and the server also serves them over HTTP. The streaming events come out as the kind of union I wanted:
export type PromptEvent = { type: "TextDelta"; text: string } | { type: "ThinkingDelta"; text: string } | { type: "ToolStart"; id: string; name: string } | { type: "ToolInput"; delta: string } | { type: "ToolResult"; id: string; content: string; is_error: boolean } | { type: "Complete"; session: ApiSession } | { type: "Error"; message: string };
It also showed where ferrotype falls short.
Where it falls short
It reads #[ts], not #[serde]. The attributes have serde's names, but the derive only looks at its own. In agent-foundry, SessionDetail has #[serde(flatten)] on its info field, so the JSON puts the session's fields at the top level. The generated type says { info: ApiSession; messages: ApiMessage[] }. That's the drift ferrotype exists to prevent, and nothing caught it, because the UI only ever read messages. The same goes for #[ts(optional)]: it's only true if serde actually leaves the key out. Every attribute has to be written twice, and a forgotten one is a type that lies. The fix is to read serde's attributes, not to mirror them: the type should come from the same source of truth as the JSON.
Registration still ended up manual. agent-foundry doesn't use from_distributed(). It lists 14 types by hand, twice, once for the file and once for the endpoint.
Generics aren't generic. Paginated<User> renders as a plain type called Paginated with User[] inside, not type Paginated<T>. Register Paginated<User> and Paginated<Post> and they share a name, so the second is dropped. TypeDef can describe a generic definition, like the Core<T> pattern ace's types use, but you have to build that tree by hand. The derive won't, and generic types aren't auto-registered.
Smaller gaps. Types come out in registration order, so Post can appear before the User it refers to. TypeScript doesn't mind, but the README says otherwise. One-file-per-module output doesn't write imports between the files yet. Every type is on one line.
Where it stands
ferro-type 0.2.0 has been the current release since January 12. The last commit, in March, added CI: rustfmt, clippy with warnings as errors, tests and docs. All 258 Rust tests still pass on today's stable Rust. The repo still carries leftovers: a TypeScript client package from the RPC days that was never published, and ferro-type-import, an unpublished crate that goes the other way, parsing TypeScript with swc and writing Rust structs and enums with serde derives.
What I'd keep
The types were the product. The RPC library I asked for was a wrapper around them, and the plan changed the evening that became clear.
Continued from page 66 Types Worth Keeping
Starting the core over was the other big call. The new core went from an empty trait to 0.1.0 on crates.io in about a day and a half, and it never had to keep the RPC code's tests passing along the way.
Make the mistakes compile errors. Workers that can't look at what they built can still run cargo build. Indexed access checking follows a rule I'd given the swarm on another project: "compile time errors ARE the goal."
And the release chores are part of the work: bump the crates together, check every README on every release, and write the examples from scratch. I'd pasted private ace code to show the types I wanted, and the README needed different ones.
The name is an old one. A ferrotype is another name for a tintypenote 3, a photograph made on a thin sheet of iron. Ferro for iron, which is to say Rust, and type for types.
From the archive2022
Design Docs
Design Docs, Version Two
Design Docs is a free, public-domain guide for designers trying to get their first job in tech. Version two moved the words out of the code so other people could write them too.
Design Docs is a guide for early-career designers. It's meant to give them a roadmap: how to improve their craft, put a portfolio together, and get their first job in the industry, or the job they're chasing.
Version two is out. It's a new site built in Next.js, the content has been through another pass, and you can read it here.
What's in it
There's no single right way into this industry, and the guide says so on its first page. It lays out some common paths and roles and how they fit together, and it says plainly that you don't have to follow anyone's path, including this one.
The sections run in order: build a foundation, discover the roles, have work to show, have a portfolio, talk about your work, and further reading. Most of the advice is the kind I wish I'd had early. Don't over-invest in your website: spend about a week on a first portfolio, and only build something elaborate once you have at least three strong projects. Don't use placeholder content in your designs. Show, don't tell. A long write-up doesn't make a better project.
Everything is CC0, which means public domain. Anyone can use any of it for anything, including copying it word for word into something commercial. I think education belongs in the public domain without strings attached.
Why rebuild it
The first version went up in November 2021, built in Nuxt in about five days. Every section was a Vue page, with the words written straight into the markup. That was fine while I was the only one writing it, and it got in the way of anyone else.
In version two the words live in seven MDX files in a content folder, and one template renders them. If you want to fix a paragraph you edit a text file, not a component. Most of the work since has gone into making the project easy to contribute to: a short CONTRIBUTING.md, a TODO list in the README, and tasks broken down over the next few versions so there's always something clear to pick up.
Who I want writing it
I don't want this to stay one designer's opinions. I'd like it to show work from a wide range of designers and to bring in people from many backgrounds to review it and add to it over time. One of the short-term items on the TODO list is to rewrite the content from a more neutral point of view, with opinions kept in clearly attributed blocks, so the guide can hold more than one voice.
How to help
Everything is open for contributions. Send a pull request, open an issue, or start a discussion on GitHub. If you'd rather not use GitHub, CONTRIBUTING.md has an email address, and content, links, reviews and feedback are all welcome that way too.
I'd like Design Docs to become a resource students and new designers can use wherever they are.
From the archive2021
fig-gen
Sixty Gens in Two Hours
fig-gen is a small generative series I made in Figma with Automator, a plugin that builds designs from stacked actions. One-pixel frames, placed by the trig functions I last used on a graphing calculator in high school.
I spent an evening trying out Automator, a new Figma plugin from Jordan Singer. You build a design out of stacked actions (create a frame, repeat, set a variable) instead of drawing it. A minimal generative series, made from pixels and equations, seemed like a good way to find out what it could do.
Nesting and rotating
I started by nesting shapes. What happens when you nest a shape and rotate it, over and over? Some of the results were good, but they all started to look alike, because rotation was doing nearly all the work.
Back to the graphing calculator
Then I remembered playing with graphing calculators in high school. Sin, cos and tan gave me interesting results right away.
Once I had a few directions I liked, I wanted them to sit together in a series, so each one needed a frame. I used the simplest outline I could, to keep everything monochrome and on theme.
What one gen is
None of the final gens use nesting. Each one is a black frame, 128 by 512, with a one-pixel white border inside it. A Repeat action runs 256 or 512 times, and on each pass it places one white mark. A mark is a frame one pixel tall. Its x position comes from the repeat's {{index}}, so the marks march along the x-axis like a plotted graph. Its y position is a function of the index.
The functions are mostly sin, cos and tan, a random variable and {{index}}, combined in different ways. The simplest saved recipe, gen3, sets two variables and places a one-pixel dot:
sin = Math.sin(2 * index)
cos = Math.cos(index)
y = cos * (index + sin) + 256Multiplying by the index makes the swing grow as the index climbs, so the dots open out into a chevron. Later recipes give each mark a random width, from a fraction of a pixel up to 6, 8, 16 or 32 pixels, depending on the recipe. The width is the product of two random numbers, so most marks are short and a few run long. Some recipes give each mark a random opacity too.
Sixty in two hours
My favourite thing about Automator was how fast it let me explore. I made about sixty gens, with a lot of variety between them, in under two hours.
Favourites
The files
I saved some of the recipes as I went. Six of them are in the fig-gen repo as Automator JSON, which you can load from Automator's settings with Import JSON. Do whatever you want with them.
Automator is gone now; its site survives in the Wayback Machine. The recipes in the repo are a record of how each gen was built rather than something you can still run, and the images below are what they made.
From the archive2021
Writing
Writing with ADHD
Geraint Evans named four ADHD traits that show up in how people write. I'd found out that year that I have ADHD, and he was the first person I'd seen describe the way I write.
This is a response to Geraint Evans's “This is how an ADHD person actually writes.” Start there if you can.
I'm a terrible writer
Or at least, I've convinced myself of that my whole life. What I write flows awkwardly. I've never understood all the rules, or how to make writing sound immersive and natural. It ends up verbose and it wanders. I mix tenses constantly.
I never understood why I write the way I do until the beginning of this year, when I found out that I have rather severe inattentive ADHD.
Then I came across Geraint's tweet, and it blew my mind. It was the first time anyone had described the way I write. Reading the replies under it, from lots of ADHD people who recognised themselves too, I realised I wasn't alone. Honestly, you should probably read his article instead of this one, but I'll try to add my own thoughts on top of it.
At the end of his article Geraint names four traits that show up in ADHD writing, so I'll use them as my structure. (Sorry if the structure doesn't hold. You know. ADHD. ¯\_(ツ)_/¯)
Impulsivity: “Impulsivity & rushing through tasks - I need to say this now!”
Attention to detail: “Yeah, probably fine, ill just post it.”
Being socially inappropriate: “Sorry, why do I have to change how I communicate when you clearly understand what I mean?”
Feeling misunderstood.
Impulsivity
Gotta go fast. My flavour of ADHD means a mind that is full of ideas all the time. You know the expression “drinking from the fire hose”? It's like that, for eight or more hours a day, every day, before I run out of energy completely and crash. (That's a topic for another time.)
Even sitting down to write this, it's hard to move on to the next sentence, because there are 18 ways I could continue this one, and 14 of them probably change the subject.
Each idea feels incredibly pressing, like the entire world should hear about it. Then I tweet it, or bug a coworker, or tell my partner, and find no interest at all.
So writing with ADHD means TONS of editing passes, even on a few paragraphs. I've usually crammed so many ideas into a small space that it's overwhelming and disjointed to read. It's hard to follow because I've left out the framing in between. That context is in my head, and I forget that other people can't see it.
Too much passion
With that many ideas, and a passion for a topic that burns hot and fast, I often oversell things or frame them wrong.
I once wrote an email to a director at the company I worked for, flagging a product decision as “unethical,” because I believed strongly that we were getting it wrong. It was a bad decision. But “unethical” was the wrong framing and the wrong severity. Would craft and inclusivity both suffer from it? Yes. Was it any more or less serious than the thousands of decisions the company made every day? Not really. In the moment, though, it felt like the most important thing in the universe, and my passion was turned up to 11.
Luckily, that director was very empathetic. They explained to the email thread what I probably meant, and why I was overreacting, without being condescending. It opened my eyes, and I've always remembered it. Stick with the people who will call you out with love. They'll help you grow enormously.
I feel like I've explained absolutely nothing here, so it seems like a great time to move on.
Attention to detail
Geraint's line nails it: “Yeah, probably fine, ill just post it.” Somehow this sits alongside the last trait, feeling misunderstood, even though the two pull in opposite directions.
For me it shows up in one of two ways. Either I write for hours, trying to get something right, or concise, or understandable, until I get frustrated and hit post. Or I write something in 30 seconds and send it without reading it back.
I could never write papers in school. When I tried to edit a long piece, I didn't edit it. I rewrote the whole thing on every pass. In the end I started writing papers a few hours before the deadline, so I wouldn't have time to fret over the content or the style. I often got better grades and responses by putting out a first pass than by trying to write the way other people would easily understand. Of course I couldn't do that. My brain doesn't work that way.
Deadline adrenaline
I call that “deadline adrenaline,” and I've come to lean on it in every part of my life. I'm not sure if it's strictly an ADHD thing or a me thing.
Deadline adrenaline means leaving something so late that I'm basically fucked if I don't finish it in the next few hours. School papers, project deadlines, even packing up a house or submitting documents: a lot of things in my life get done this way.
It lets me pull off tasks that seem impossible, with ease. But it can make it hard to work well with other people. My managers are always terrified, and then shocked when I turn around an entire project in a day, or finish what looked like an impossible amount of work in a short time.
Continued from page 80 Writing with ADHD
It's part of the ADHD cycles I go through, which I'd like to write about separately. The short version: when I'm not on medication, I go through pretty severe cycles, roughly daily and roughly monthly, in energy, creative output, focus and more. That makes it very hard to work with others when my creative or energy highs don't line up with where they are. When my highs do line up with a project or a collaboration, people are often amazed by how much I make and how good it is. And then, probably, confused a week later when I can't seem to make two screens.
That was a tangent. On to the third trait.
Being socially inappropriate
This one is straightforward for me. First, I recognise that I'm VERY, SUPER privileged to be able to work this way.
I don't work with people or companies that require me to do things a certain way. Need to dress a certain way? Nope. Talk or write formally? Nope. Maybe that seems messed up, but it's how my brain works.
I've been lucky to find a career where I could design a way of working that suits me and still brings people value. I think many ADHD people never get the chance to show what they can do, because they can't engage with the world the way the world wants them to. I was lucky to have some incredible teachers and mentors who saw value in me despite a “weirdness” I couldn't name until recently.
We leave a lot of great people behind because they can't, or don't, work the way the world expects. If my only options had been a suit and tie, or being on my feet in retail at 8am, I'd still be in my parents' basement. (People in retail: you're the unsung heroes, doing some of the hardest work for completely unjust pay. Thank you. I hope the way you have to work improves in my lifetime.)
I've always done worst when I tried to fit myself into the mould of “normal.” I come across as just an OK-ish designer. In the past few years I've found real success by leaning into being myself, unapologetically, even though I know that can be hard to work with and around. I have the utmost respect for managers and leaders who can take odd-shaped puzzle pieces like me and work out where they fit.
Feeling misunderstood
The biggest one. For me, feeling misunderstood is the root of almost all the social problems ADHD brings.
If I feel someone didn't understand what I said, here come ten minutes of explanation of that one statement, while the group slowly edges away from the odd guy.
Post something online? “People will pick apart every statement.” “Don't post that, people won't understand what you mean.” The self-censorship that comes from fearing being misunderstood is socially crippling, and it's bad for your career.
When I worked at Facebook, I always struggled with one expectation: as you become a more senior designer, you build a public following and start to have an impact on the industry. Even thinking about that was crippling, because of the fear of saying or doing something wrong and being cast out.
For an ADHD person, feeling misunderstood is TERRIFYING. There are a thousand minor slights and awkward moments I still think about every week, begging in my head to go back and explain or correct them.
Wrapping up
Even starting this wrap-up, I feel like I've written a lot of disjointed stuff that people won't get much out of. I'm putting it out anyway, because I'm trying to get better at challenging my assumptions, and to learn more about myself and my ADHD by sharing with others and getting feedback.
This wandered away from writing a bit. But writing is HARD for ADHD people. I'm trying to do more of it to get better, and to work out how these four things affect my writing and how I can improve it.
I wrote this in one pass, so apologies for any problems with spelling, flow or anything else. (That was true in 2021. This version has been edited since.)
I hope it's interesting or useful to someone.