Giving Every Agent Its Own Branch: Git Worktrees for Parallel AI Work

Run two coding agents against a single working tree and they will fight. One is halfway through editing app/api/messages/route.ts while the other checks out a different branch underneath it. The index lock flickers. A git stash from one session swallows the other’s uncommitted work. I have lost real edits this way, and every time the root cause was the same: one working tree, one HEAD, two writers.

The InterlinedList repo already had matching agents for the jobs I hand off most: a Next.js implementer, a migrations specialist, unit and e2e testers, a docs writer, security and UX reviewers. They are good at their lanes. What they were missing was a lane in the literal sense. They all drove on one road.

I found the pattern I wanted written up in Augment’s guide to git worktrees for parallel AI execution, and it maps almost one to one onto how I already think about agents. This is the writeup of what I built on top of it for this repo: the directory convention, the seven scripts that manage the lifecycle, and the wiring that makes every agent use them without being reminded.

The one-tree problem

A normal clone gives you a single working directory backed by one .git. That is fine for one person doing one thing. The moment you parallelize, the shared mutable state (the working files, the index, the current branch) becomes the bottleneck. You cannot have the migrations agent on agent/add-webhooks and the docs agent on agent/help-refresh at the same instant, because “the branch” is a property of the whole checkout.

Diagram illustrating 'The one-tree problem' in software development with two agents, A and B, interacting with a mutable checkout and related issues like index.lock flicker and checkout thrash.

Two writers, one mutable checkout. The collisions have nothing to do with the work: an index lock flickers, a git stash swallows the other session’s edits, HEAD thrashes between branches.

Git solved this in 2015 with git worktree. A worktree is a second (third, fourth) working directory attached to the same repository. Each one has its own files, its own index, and its own checked-out branch, while sharing one object store on disk. The object store is the expensive part, so you share it. A directory of files is cheap, so you duplicate it per task. That is exactly the tradeoff you want for parallel agents.

Diagram illustrating a Git object store with multiple worktrees, highlighting shared storage and individual configurations for each task.

Share the expensive part, duplicate the cheap part. The object store lives on disk once; the working files, the index, the checked-out branch, and even the dev-server port are private to each worktree.

The convention

Every agent works in .trees/<task-id> on a branch named agent/<task-id>, cut from origin/develop (this repo integrates on develop, not main). So a task called add-list-webhooks lives at .trees/add-list-webhooks on branch agent/add-list-webhooks. The directory name and the branch name always agree because both are derived from the same sanitized slug.

The .trees/ container is gitignored. Worktrees are workspace, not history:

# .gitignore
# Agent worktrees (see scripts/worktrees/)
.trees/

That single ignore line is the whole footprint the pattern leaves in the tracked tree. Everything else is scripts and instructions.

The scripts

I did not want agents (or me) typing raw git worktree incantations and getting the branch name wrong, or forgetting to copy .env.local, or leaving stale metadata behind. So the lifecycle lives in scripts/worktrees/ as a small set of focused shell scripts. Here is every one of them.

Diagram illustrating the worktree lifecycle in Git, detailing steps for creating, inspecting, removing, and cleaning worktrees. Sections include 'STEP 1' for branch creation, 'WHILE WORKING' for listing and locking, 'WHEN IT LANDS' for removing branches, and 'DAILY SWEEP' for cleanup processes.

The whole lifecycle before the per-script detail. Create, inspect while working, remove when it lands, sweep the merged trees on a schedule.

_lib.sh is the shared library the others source. It holds the functions that keep conventions consistent: wt_repo_root resolves the primary checkout even when you call it from inside a linked worktree (it reads git rev-parse --git-common-dir and walks up), wt_sanitize lowercases a task id and strips it to [a-z0-9._-]wt_resolve_base fetches and prefers origin/<base> over a local branch, wt_port_for hashes a branch name into a stable dev port, and wt_is_locked reads the porcelain worktree list to check lock state. Nothing in it is clever. It exists so the clever bits are written once.

wt-create.sh <task-id> [base] is the one agents call first. It creates the worktree and makes it ready to work in, in one shot:

scripts/worktrees/wt-create.sh add-list-webhooks

Under the hood it resolves the base ref (default develop), ensures .trees/ is in .gitignore, turns on git rerere so repeated conflict resolutions replay across parallel merges, runs git worktree add -b agent/<slug> .trees/<slug> origin/<base>, copies the root .env.local into the worktree, appends a deterministic DEV_PORT derived from the branch name, runs npm ci --prefer-offline, and finally locks the worktree so other sessions can see it is in use. Flags let you opt out where it makes sense: --no-install skips the dependency install for a quick plumbing check, --no-lock leaves it unlocked, and --baseline runs the test suite right after setup so a green baseline proves any later failure came from the agent’s change rather than a pre-existing break.

The port assignment is worth a sentence. Two agents both running next dev on 3000 is another collision, a quieter one. wt_port_for runs the branch name through cksum and maps it into the 3100 to 9998 range, so each worktree gets a stable, distinct port written into its own .env.local. Start the server with npm run dev -- -p "$DEV_PORT" and two dev servers coexist.

wt-list.sh answers “what is running right now.” It prunes stale metadata, then prints one row per worktree with path, branch, lock state, and short HEAD:

PATH BRANCH LOCKED HEAD
/Users/adron/Codez/interlinedlist feature/fix-blog-image - 58afa91562
/Users/adron/Codez/interlinedlist/.trees/add-list-webhooks agent/add-list-webhooks yes b78faf9e21

Before an agent touches a shared file, it can look here and see who else is holding what. Git will not warn you about two branches editing the same file, so this list plus discipline about non-overlapping file domains is the actual safety mechanism.

wt-lock.sh <task-id> [reason] and wt-unlock.sh <task-id> are thin wrappers over git worktree lock/unlock. A lock is advisory: it resists prune and move, and it is the signal in wt-list.sh that says “an agent is live in here, do not reap this.”

wt-remove.sh <task-id> is the teardown. It unlocks if needed, runs git worktree remove, and prunes. This matters more than it looks: deleting a worktree with rm -rf leaves dangling metadata in .git/worktrees/ that haunts you until the next prune. The script never does that. --force discards uncommitted changes on purpose, and --delete-branch drops agent/<task-id> in the same step when the work has landed.

wt-cleanup.sh [base] is the bulk sweep. It walks every worktree physically under .trees/, and for each one whose branch is already an ancestor of origin/develop (in other words, merged), it removes the worktree and deletes the branch. It deliberately skips anything outside .trees/, so the primary checkout and any sibling worktrees I keep elsewhere on disk are never touched. This is the script you point a daily cron or a post-merge hook at.

Each of these is also exposed as an npm script, so npm run wt:create -- add-list-webhooksnpm run wt:list, and npm run wt:remove -- add-list-webhooks all work if you prefer that entry point. There is a scripts/worktrees/README.md documenting the whole set alongside the code.

Wiring it into the agents

Scripts nobody runs are decoration. The point was to make every agent reach for a worktree by default, so I added the instruction in three places at three levels of specificity.

At the top, CLAUDE.md now states the standing rule: every agent works in an isolated worktree, torn down with the lifecycle scripts. That is the repo-wide contract.

In the middle, two shared protocol docs under .claude/workflows/ hold the full detail: worktrees.md spells out the create-work-remove lifecycle, the boundaries (shared object store, non-overlapping files, and the important caveat that the database is shared even though the files are not), and plan-first.md covers the companion habit I wrote about separately. Every agent and skill links to these rather than repeating them.

At the leaf, each of the eight agent definitions in .claude/agents/ got a “Work in an isolated git worktree (required)” section written for its job. The implementers (Next.js, migrations, tests, docs, blog) get the full create-work-remove flow. The two read-only reviewers (security, UX) get a variant that tells them to cd into the worktree under review and read its diff, and explicitly not to create, lock, or remove anything. The migrations agent gets an extra warning in bold, because the worktree isolates schema.prisma and the migration files but not the Postgres instance: db:migrate still hits localhost and db:migrate:deploy still hits production from any worktree. That is the one place the isolation is a lie, and the agent needs to know it.

The five paired skills in .claude/skills/ (the ones that back the implementer agents) got a short “Worktree-first, plan-first” block near the top pointing at the same protocol docs, so whether the work comes in through the agent or the skill, the instruction is there.

Proving it works

I ran the whole lifecycle before committing any of it. Create a worktree from develop, confirm it is locked and has its port, unlock and re-lock it, remove it with the branch, and verify the cleanup pass leaves the sibling worktrees alone:

scripts/worktrees/wt-create.sh wt-smoke-test --no-install
scripts/worktrees/wt-list.sh # shows agent/wt-smoke-test, LOCKED yes
scripts/worktrees/wt-unlock.sh wt-smoke-test
scripts/worktrees/wt-lock.sh wt-smoke-test "smoke re-lock"
scripts/worktrees/wt-remove.sh wt-smoke-test --delete-branch
scripts/worktrees/wt-cleanup.sh develop # safe no-op, siblings untouched

Every step did what it said, the .gitignore guard refused to duplicate the .trees/ line it found already present, and the sibling worktree I keep for feed-perf work was never in scope for cleanup. That last part was the thing I most wanted to confirm, because a cleanup script that reaches outside its sandbox is worse than no cleanup script.

Where agents still get confused

Isolating the filesystem fixes the filesystem. It does nothing about the fact that the work itself overlaps, and there are a handful of ways an agent still gets lost.

The database is one instance, and every worktree writes to it. I flagged this to the migrations agent in bold, but it deserves more than a warning. If the migrations agent adds a column on agent/add-webhooks, that column now exists in the same localhost Postgres every other worktree points at. The docs agent three trees over never sees the changed schema.prisma, yet its queries hit the mutated database anyway. Additive-only migrations keep this survivable most of the time, since an extra column nobody reads is harmless. But the moment two agents touch the same table, or one runs db:migrate:deploy and reaches production from what looked like a sandbox, the isolation is a fiction. The files are private. The database is not.

Green in isolation, red on merge. This is the one that bites. Agent A changes a function signature in lib/lists/queries.ts. Agent B, on its own branch, calls that function from a route it owns. Their files never overlap, so wt-list.sh shows no conflict and git stays quiet. Both test suites pass, because A’s worktree still holds B’s old caller and B’s worktree still holds A’s old signature. It’s all green right up until both branches land on develop, and then the integration is broken in a way neither agent could see from inside its own tree. Worktrees convert a loud, immediate collision into a quiet one that surfaces later. Often a fair trade, but a trade.

Stale base drift. Every worktree is cut from origin/develop the moment it’s created. Agents aren’t always short-lived. Let one run for a few hours while three others merge back, and it’s now building on a develop that no longer exists. It will reintroduce a helper that got deleted upstream, or write against an API another agent already reshaped. The worktree has no idea the ground moved under it. Nothing in the create-work-remove loop forces a re-fetch, so a long-running agent drifts out of date without noticing.

“Does this already exist?” stops having one answer. With five branches in flight, whether feature X is “already built” depends on which tree you grep. An agent that checks the primary checkout won’t see work-in-progress on another branch, and it will happily build a second copy. I’ve watched two sessions independently implement overlapping halves of the same feature, each sure it was first, because neither branch was visible to the other and neither said up front what it was about to touch. wt-list.sh tells you which branches exist. It says nothing about what each one intends to change.

The lock is a suggestion. wt-lock resists prune and move, and it flags a tree as live in the list, but it will not stop another agent from opening the same file on its own branch and editing away. The real guard against two agents clobbering one file is the up-front decomposition plus the discipline to run wt-list.sh and honor what it shows. An agent that skips the check has no seatbelt, just the shape of one.

Abandoned trees pile up. wt-cleanup.sh only sweeps branches already merged into develop. A worktree from a crashed or cancelled session is unmerged, still locked, and invisible to the sweep. It sits on disk with a full node_modules until someone removes it by hand. And an agent resuming a task can trip over a half-finished tree from an earlier run and read its stale state as current work.

Losing the current directory. The workflow says cd .trees/<task-id> and do everything there. Shell state doesn’t always survive between tool calls, and an absolute path into the primary checkout looks identical to one into a worktree. An agent that loses track of where it is can read the main checkout’s copy of a file, reason about it as though it were its branch’s version, and edit the wrong tree. The isolation holds only as long as the agent keeps its bearings.

What this buys

The honest version: worktrees do not make independent tasks independent. If two agents both need to edit the same route, isolating their filesystems just delays the merge conflict, it does not prevent it. The decomposition still has to be real. What the pattern removes is the accidental collision, the kind that has nothing to do with the work and everything to do with sharing one mutable checkout. Those were most of my pain, and now they are gone by construction.

Advantages and disadvantages

Advantages

  • Accidental collisions disappear. Each agent gets its own files, index, and HEAD, so index-lock flicker, a stray git stash eating another session’s edits, and checkout thrash stop happening.
  • Every task lands on its own agent/<task-id> branch, which keeps review and merge clean and stops one task’s half-finished mess from bleeding into another’s diff.
  • The object store is shared, so the costly part of the repo lives on disk once and spinning up another tree is cheap.
  • Each worktree gets a deterministic DEV_PORT, so several next dev servers run side by side instead of fighting over 3000.
  • The lifecycle is scripted and wired into every agent definition, so the right setup and teardown happen without anyone remembering the incantation.
  • git rerere is on by default, so a conflict you resolve once replays across the parallel merges that hit it again.
  • Cleanup is fenced to .trees/, so the bulk sweep never reaches the primary checkout or the sibling worktrees I keep elsewhere.

Disadvantages

  • The database isn’t isolated, and neither is anything else global (production through db:migrate:deploy, OAuth apps, third-party rate limits). File isolation quietly implies an isolation that isn’t there.
  • Overlapping edits on separate branches turn into silent, deferred conflicts and semantic breakage that pass every isolated test and only show up at integration.
  • A long-running worktree drifts from a moving develop, and nothing in the loop forces the re-fetch that would catch it up.
  • Locks are advisory, so the actual protection against two agents editing one file is decomposition plus discipline, not anything git enforces.
  • With several branches live, “does this already exist” has no single answer, and agents duplicate each other’s work when branches can’t see one another.
  • Every worktree carries a full node_modules, so disk use and npm ci time multiply with each active task.
  • Crashed or abandoned sessions leave locked, unmerged trees that the merged-only cleanup won’t reap, so someone clears them by hand.
  • Coordination is still manual: the tooling shows which branches exist, not which files each agent means to touch.

The next habit I want in every agent sits upstream of all of this: stop and plan before touching a single file. That one is worth its own post.

More solutions to the confusion above are coming in the next few posts. Subscribe so you don’t miss ’em: drop your email into the box just below this post, or grab the RSS feed.

I’m Adron, brainstorming and building InterlinedList.

A Research Agent on a Budget Leash, and How I’d Sequence the Whole Build

Part 8 of 8: Durable AI for InterlinedList


Back in Part 1, I said the whole series was one argument dressed up as eight posts. LLM tokens are the expensive thing, Temporal is not, and Temporal’s mechanics structurally push the token bill down. I also said the way I’d build it was to brainstorm against the real code first, then hand the agent structure instead of hope. This is where those two threads tie together.

The capstone I keep circling back to is the one that sounds the most like science fiction and is actually the most dangerous to ship carelessly: “research topic X from my saved links and draft a document with citations.” The agent retrieves. It reasons. It drafts. And if I’m not careful, it also burns through a month of my inference budget in one runaway loop while I’m asleep.

So this post is two things. The agent itself, and the playbook for building everything that came before it. The agent is the thing you build last, and the order it sits on top of is the part I actually want to argue for.

The Capstone Is Just the Earlier Pieces, Composed

This part should feel earned by now. The agentic-research-to-draft phase, the last of the proposals, doesn’t invent anything. It composes.

The link content from Part 5 (the crawl-and-cache that turns your saved URLs into readable text) is the agent’s source material. The retrieval from Part 5’s second half, the pgvector search over your own docs, lists, and messages, is how the agent finds the relevant three paragraphs instead of stuffing forty links into context. And the durable generation from Part 3, the workflow that survives a timeout and asks before it writes, is the drafting step.

Compose those three and you get an agent loop: retrieve, reason, retrieve again, draft, cite. Each of those steps is already an activity I’d have built and tested for its own feature. The agent is a workflow that calls them in a loop the model steers.

Which is exactly where it gets scary. A loop the model steers has no natural stopping point. It’ll keep deciding it needs one more search, one more fetch, one more pass at the draft. That’s not a bug in the model. That’s what an agent is. The bug would be letting it run unsupervised.

Temporal Supervising an LLM Agent Is a Nice Recursion

I like this framing because it’s a little bit funny. Temporal is an orchestrator whose entire job is supervising unreliable, long-running, retry-heavy work. An LLM agent is unreliable, long-running, retry-heavy work. So the agent runs inside a Temporal workflow, and the workflow is the adult in the room.

Three guardrails, all first-class workflow logic, none of them prompt-hope:

hard token budget. Before every model call, the workflow calls assertBudget, the same primitive from Part 7 that reads the AiGeneration ledger and throws a non-retryable BudgetExceeded when the user’s daily or monthly ceiling is hit. The agent can want another search all it likes. If the budget’s gone, the call never fires.

max-step cap. The loop counts its own iterations. Twelve steps, or whatever I tune it to, and it’s done. It drafts with what it has rather than spiraling.

wall-clock timeout. Temporal’s workflow timeout means even a wedged agent eventually stops, cleanly, with whatever it produced so far.

And here’s the piece that makes the whole thing cost-sane instead of terrifying: every step is memoized. If the agent fails at step 9 of a 12-step loop (provider hiccup, worker restart, whatever) the workflow resumes at step 9. It does not re-run steps 1 through 8. It does not re-pay for the eight model calls it already made. In a serverless handler, a mid-loop failure means starting the whole expensive agent over. Here it means retrying one step.

Sketching the loop, this is the shape I’d hand Claude to fill in:

// Proposed: the supervised agent loop as a Temporal workflow.
export async function researchAgentWorkflow(input: ResearchInput): Promise<Draft> {
  const MAX_STEPS = 12;
  const context: Snippet[] = [];

  for (let step = 0; step < MAX_STEPS; step++) {
    // Governor first: a hard ceiling the agent cannot talk past.
    await assertBudget(input.userId);

    // Cheap model plans the next move over the small retrieved context.
    const move = await planNextStep({
      model: "claude-haiku-4-5",
      goal: input.topic,
      context,
    });

    if (move.type === "done") break;

    // Retrieval is mechanical and nearly free. No expensive tier here.
    const hits = await retrieveFromLinks(input.userId, move.query);
    context.push(...hits); // stays small: top-k snippets, not whole pages
  }

  // Expensive tier runs exactly once, on the final synthesis.
  return await draftWithCitations({
    model: "claude-opus-4-8",
    topic: input.topic,
    context,
  });
}

The cost story lives in the model choices, not the prose. Retrieval and planning run on a cheap tier: Haiku for the “what should I look for next” decisions, which are frequent and small. Opus 4.8, the expensive tier, runs once, on the final synthesis, over a retrieval-augmented context that stayed deliberately small the whole way through. I’m not paying Opus prices to decide which link to read next. I pay them for the draft, and that’s the one thing in the loop I think is worth it.

No clever prompt is doing that. The structure is.

How I’d Sequence the Whole Build

If you’ve read the series straight through, you might be tempted to build the shiniest thing first. Don’t. The agent ships last, and the ordering underneath it is the real deliverable of this post, because every step ships value on its own and nothing is a big-bang.

Here’s the order I’d hand myself:

1. The foundation phase: the Temporal worker and the cost primitives. The worker, the client wiring, the llmCall activity with the model cascade, assertBudget, the response cache. This is the groundwork everything else stands on, and nothing works without it. Mostly infra, not code, and it’s a week.

2. The durable publish and idempotency fix, pure win, no LLM. This is the one from Part 4: the every-minute cron that can double-publish because a partial failure re-runs on the next tick. Rebuild it as a Temporal Schedule with the message id as the workflow id, so a given message can never be published twice. Zero model tokens. It fixes a current production hazard. If I could only ship one thing from this whole series, it’d be this.

3. Durable Generate plus confirm. Part 3. Long docs stop blowing the function budget, and a wrong list schema never gets silently written because the workflow pauses on a signal and waits for a human. This is the in-product generate button, on solid footing.

4. Link crawl, then retrieval. Part 5. Crawl the links into a cache (the cheapest, highest-cache-value piece in the set) then layer pgvector retrieval on top. This is “where did I save that article?”

5. Batch enrichment, then prompt-to-list. Part 6. The fan-out that tags everything and expands anything without re-billing completed calls, then the “build a list from a sentence” flow composed on top of it.

6. The AI calendar and digests. Part 4’s second half. Now that publishing is durable, the calendar planner and the scheduled digests have solid ground to schedule onto.

7. The cost dashboard, then the agent. Part 7’s dashboard makes “cheap” visible: tokens, cache-hit rate, budget remaining. I want that governor on-screen before I ship the most token-hungry feature in the whole set. Then, and only then, the agentic capstone.

The rule underneath the whole ordering is that value ships at every step. The publish fix helps users who never touch AI. The link cache is useful before any retrieval sits on it. The dashboard is useful before the agent exists. I’m never in a state where I’ve spent three weeks and have nothing a user can hold.

The Verification Is Where the Trust Actually Lives

The meta-point of the whole series is this one, so I’ll say it flat. You do not trust an autonomous agent because the prompt was good. You trust it because it runs inside guardrails, and because tests prove the properties you care about are there.

So the verification for the agentic capstone, and everything under it, is not an afterthought. It’s the thing that makes any of this shippable:

Vitest unit tests for the activity logic and the cost math. Does the cascade pick the cheap model for planning? Does assertBudget throw at the right ledger sum? Pure functions, no DB, fast.

Temporal replay tests that prove a workflow resumes correctly after a simulated worker crash. This is how I prove, not hope, that a retry doesn’t restart the expensive agent from step zero.

An idempotency test that starts two publish workflows with the same message id and asserts exactly one publish. That’s the double-post race, closed and locked.

Cost-assertion tests, the ones I care about most for the agent. Fail an activity mid-pipeline and assert the already-completed model calls are not re-invoked on retry. Memoization is money, and this test is how I know the money’s saved.

Playwright E2E for the generate to confirm to write flow, so the human-in-the-loop gate works in a real browser and not just in my head.

A security pass before any PR: SSRF on the crawler (everything through safeFetch), IDOR on every workflow write (where: { id, userId }), prompt-injection defaults (artifacts private by default so a poisoned link can’t publish on your behalf), and budget-bypass attempts.

And every schema change (the LinkContent table, the pgvector column, the response cache) goes through the strict additive migration workflow. Hand-written, idempotent, applied to both databases. No prisma db push, no shortcuts. That workflow has broken production before, precisely when someone skipped it.

What I Actually Learned Working With Claude

The lesson closes the loop the series opened. Supervise the agent with structure, let the tests carry the trust, and it turns out that’s also how I’ve been building with Claude the whole time.

You don’t hand an agent a vague goal and hope. You hand it constraints: a budget it can’t exceed, a step count it can’t blow past, a timeout it can’t outlast. Then you make it write the tests that prove those constraints hold, the replay test, the idempotency test, the cost-assertion test. What makes the agent trustworthy is the wall around it, not how nicely I phrased the ask.

And that’s true one level up too, which I didn’t expect going in. Sequencing the build so each step is independently verifiable (the publish fix before the calendar, the cache before retrieval, the dashboard before the agent) is the same move as capping the agent’s steps. Give the work boundaries, make the boundaries checkable, prove them before moving on. Whether the thing on the leash is a research agent or my own plan to build one, the leash is what I trust.

I’ve been brainstorming this out loud for eight posts. The pieces are grounded in code that already exists: the safeFetch guards, the DSL, the crosspost fan-out, the cron that really does risk a double-post. None of it is shipped. All of it is buildable, in the order above, with value at every step. So I’m going to start at step one and build the durable publish fix, because it’s a pure win with no model tokens and it closes a real hazard. I’d love for you to follow along, as I am going to move forward now and implement this. If you’re interested, subscribe!

Adron brainstorming and working on InterlinedList.

Making LLM Features Cheap Enough to Actually Ship

Part 7 of 8: Durable AI for InterlinedList


Six posts in, and I’ve been dancing around a number. Every proposal I’ve sketched (durable generate, the calendar planner, the link crawler, batch enrichment) carries a cost, and the expensive part is never Temporal. It’s the tokens. A worker box and a managed Temporal namespace run me on the order of tens of dollars a month (obviously at this time), flat, whether they process ten jobs or ten thousand. The model bill is the one that scales with usage. It’s also the one that decides whether an AI feature is worth shipping or is a slow-motion budget fire.

So this post is the payoff. Durability isn’t just nice, durability is money. The mechanics I keep reaching for aren’t in the plan because they’re clever. They’re there because each one structurally drives the token bill down, and does it in a way I can measure instead of squint at and hope for. Keeping this cheap relative to the Claude and ChatGPT APIs was never a footnote I tacked onto the series. It’s been the point the whole way through.

None of this is live yet. AI assistance in the product is still marked Coming Soon. This is me brainstorming the cost layer I’d build, and how I’d hand it to Claude to build with me.

The Single Biggest Saver Is Not Re-Paying for Work You Already Did

Start with the one that matters most. Durable memoization.

When a Temporal workflow runs an activity (say, an llmCall that generates a document section) the result gets persisted the moment it completes. If the workflow later fails and retries, it does not re-run that activity. It replays the history, sees the completed result, and moves on.

Think about what that means on a six-step generation pipeline that dies at step five. Without durability, a retry starts over: steps one through four re-invoke the model, and you pay for all four again just to get back to where you already were. With Temporal, the retry re-runs step five and only step five. Steps one through four are already-billed calls that never happen twice.

That’s the difference between retries as a cost multiplier and retries that are free. On a batch enrichment job that fails at item 47 of 50, you re-bill item 47, not items 1 through 46. I wrote a whole post about that in Part 6, and it’s the same mechanic every proposal here leans on. Memoization is why a flaky provider or a worker restart doesn’t torch the budget.

Cheapest Model First, Escalate Only on Demand

The next lever is refusing to reach for the expensive tier by default. The llmCall activity would run a model cascade: cheapest capable tier first (Haiku-class, Flash-class), escalating only when the task actually needs it.

Most of what these features do is mechanical. Tagging a message. Summarizing a row. Extracting a field. That work does not need a frontier model, and paying frontier rates for it is just lighting money on fire. So the cascade defaults to cheap, and it reserves the Opus-class tier for the thing the user explicitly asked for: long-form synthesis, a research draft, a document they told me they want written well.

Paired with that: per-target max_tokens ceilings, so a runaway generation can’t balloon. A message caps around 1k, a list around 4k, a document around 16k. The ceiling isn’t a polite suggestion the model is vaguely aware of. The activity enforces it as a hard cap. So you get the cheap tier by default, a small ceiling per target, and the expensive model only shows up when you asked for it.

A Gate That Throws Before It Spends

Cascades and ceilings keep individual calls cheap. But the scenario that scares me is unbounded spend: a runaway agent loop, or a leaked user key someone else is now happily burning. So the plan puts a budget gate before every call, not after.

An assertBudget activity reads accumulated spend from the AiGeneration ledger and throws a non-retryable BudgetExceeded when the user is over their ceiling. Non-retryable is the important word. A normal failure retries; this one halts the workflow cold, because retrying a budget breach just tries again to spend money you’ve already said no to.

Roughly what I’d hand to Claude as the shape:

// activity: runs on the worker, before any billable llmCall
export async function assertBudget(userId: string, estimate: TokenEstimate): Promise<void> {
  const spent = await getSpendThisPeriod(userId); // counts only, from AiGeneration
  const remaining = budgetFor(userId) - spent;

  if (estimate.maxTokens > remaining) {
    // non-retryable: a budget breach must not loop and re-attempt the spend
    throw ApplicationFailure.nonRetryable(
      `Budget exceeded: need ${estimate.maxTokens}, have ${remaining}`,
      "BudgetExceeded",
    );
  }
}

A workflow calls assertBudget and then llmCall, in that order, every time. Worst case is a bounded overspend of one call, never an open tap.

Never Bill the Same Request Twice

Then there’s the plain-dumb-obvious saver: caching. A response cache keyed by hash(system + prompt + model). If an identical request comes through (same system prompt, same user prompt, same model) it’s served from cache and never re-billed. Regenerate the same summary twice, pay once.

Same idea one layer down for link work: URL-hash dedup. When the crawler from Part 5 fetches and embeds a link, it keys on a hash of the URL. A link shared across ten messages, three docs, and a list row gets fetched, extracted, and embedded exactly once, ever. Embeddings are already orders of magnitude cheaper than generation, so computing them once and caching forever makes the cheapest part of the pipeline round down to nothing.

And to keep from wasting tokens on retries you caused yourself: worker concurrency and task-queue rate caps tuned to stay under provider limits. Blow past a rate limit and you get a 429 storm, and every retry in that storm is wall-clock and, on some providers, tokens down the drain. Obeying the limit is cheaper than fighting it.

One more, because it’s the most satisfying: confirm-before-spend. For the prompt-to-list flow, the workflow generates the schema, then pauses on a Signal and waits. It does not spend a single row-generation token until the user confirms the schema is right. Get the schema wrong and you’ve spent one cheap schema call, not fifty row calls against a shape nobody wanted. That pause is impossible in a serverless handler and native in Temporal.

The Ledger Counts, It Doesn’t Read

Every one of these levers needs a source of truth, and that’s the AiGeneration ledger. Critical design decision: it records counts and status only. Tokens in, tokens out, which model, succeeded or failed. It never stores the prompt text or the model output.

That’s not laziness. One table does three jobs. Privacy, because I’m not warehousing what people wrote or what the model said back. The quota counter, because assertBudget reads its sums off it. And the cost dashboard, because those same rows drive a per-user “AI usage and spend” view: tokens, calls, cache-hit rate, budget remaining. When something looks expensive, the ledger doubles as the debugging trail. You can see that a workflow made forty calls without ever seeing what it said.

Build the Guardrails With Claude, Then Make It Prove Them

Now the part I’ve been wanting to get to, because this is where working with an LLM changed how I’d approach any of it.

Memoization and caching only save money if they’re in the right places. Put a cache breakpoint one step too early and you re-bill everything after it. Miss a memoization boundary and a retry quietly re-runs a paid call. The savings are entirely a function of where the boundaries land, and eyeballing a six-step pipeline for those boundaries is the kind of thing I get wrong.

So the first move is a diagnostic question, not a code request. I’d point Claude at the actual workflow and ask: “walk this pipeline and tell me exactly where a retry would re-bill the model.” Then let its answer drive placement. It reads the activity boundaries, traces the retry path, and tells me which calls are already durably memoized and which ones a mid-pipeline failure would re-invoke. That answer is the map. The caching and memoization go where the map says, not where I guessed.

The second move matters more: make it prove the savings. Memoization you can’t prove is just a hope with good intentions. So I’d have Claude write a Temporal replay test (using TestWorkflowEnvironment) that fails an activity partway through the pipeline and then asserts the already-completed LLM activities are not invoked again on retry.

it("does not re-bill completed LLM activities on retry", async () => {
  const llmCall = vi.fn()
    .mockResolvedValueOnce("step-1 result")
    .mockResolvedValueOnce("step-2 result")
    .mockRejectedValueOnce(new Error("provider blip at step 3")) // fail mid-pipeline
    .mockResolvedValue("step-3 result (retry)");

  await worker.runUntil(client.workflow.execute(generatePipeline, { /* ... */ }));

  // steps 1 and 2 completed before the failure, so Temporal replays their
  // memoized results and MUST NOT call the model for them again.
  const stepsCalled = llmCall.mock.calls.map((c) => c[0].step);
  expect(stepsCalled.filter((s) => s === 1)).toHaveLength(1);
  expect(stepsCalled.filter((s) => s === 2)).toHaveLength(1);
  expect(stepsCalled.filter((s) => s === 3)).toHaveLength(2); // failed once, retried
});

That test is the proof. It fails loudly the day someone refactors the pipeline in a way that breaks memoization and starts silently re-billing steps one and two. The savings stop being a story I tell about the architecture and start being a property CI enforces. Same loop for the response cache: a test that fires two identical requests and asserts the second one hits cache and never reaches the model.

Ask the agent where the money leaks. Let its answer place the guardrails. Then make it write the test that locks them in. That loop works because a cost regression is invisible until the bill shows up weeks later, and a test drags it into the light on the exact commit that caused it, while you can still git blame your way back to it.

Where This Lands

Part 8 is the capstone: the agentic research-to-draft flow, the single most token-hungry thing in the whole set. It’s the reason every governor in this post has to exist before it ships. I’ll also lay out how I’d sequence the entire build, starting from the foundation phase (the Temporal wiring and the shared cost primitives everything else reuses) and working all the way up to the agent, and how I’d verify each piece as it lands.

Adron brainstorming and working on InterlinedList.

Where Did I Save That Article? Crawl, Cache, Retrieve

Part 5 of 8: Durable AI for InterlinedList


I have a problem, and I bet you have it too. I read something good, I drop the link into a post or a document or a row in some list, and then three weeks later I want it back and I have no idea where it went. Was it a message? A doc? A row in that reading-list I built? So I scroll my own feed like an archaeologist. It’s a slow-moving disaster. The whole point of writing something down is being able to get it back, and I’d built the writing-down part beautifully while ignoring the getting-it-back part entirely.

So this is the post where I brainstorm fixing that. Two pieces come from the Temporal plan, and they run in order. First, the link-crawl-and-cache phase turns saved links into a real cache. Then the retrieval phase makes that cache searchable, semantic and keyword, with cited snippets, so “where did I save that article about X” actually returns the article about X. None of this is shipped. AI assistance in the product is still “Coming Soon.” This is me thinking out loud with the intent to build.

What Actually Exists Today (Less Than You’d Hope)

Before I design anything, I made myself write down what the codebase does right now, because designing against a fantasy version of your own product is how you build the wrong thing.

Today, link metadata gets fetched for messages only. When you post something with a URL, lib/messages/metadata-fetcher.ts pulls the Open Graph tags and the result gets stored inline on the message as a JSONB blob: Message.linkMetadata. That’s it. There’s no cache table. There’s no dedup, so if you and forty other people all post the same great article, that URL gets fetched forty-one times. Documents and list rows (both Subscriber features) don’t get scanned for links at all. And search? Search today is Postgres ILIKE substring matching across three separate routes: messages, lists, documents. It’s a contains query. Type “kubernetes” and you get rows where the literal string “kubernetes” appears. Miss the spelling, miss the synonym, miss the concept, and you get nothing. There is zero vector infrastructure anywhere in the repo. I checked.

That’s the honest starting line. It’s also a decent starting line, because two of the hard parts are already solved and I’d be a fool to rewrite them.

The Crawler: Reuse the Fetcher, Reuse the Guard

So, the caching step. A durable workflow scans a user’s messages, documents, and list rows for URLs, fetches each one, extracts the readable content, and stores it in a new LinkContent cache table keyed by a hash of the URL. Deduplicated across all your content, and optionally across all users. It runs incrementally: only new or changed URLs get fetched, so the second run is nearly free.

The building blocks already exist. lib/messages/link-detector.ts gives me extractUrls and detectLinkslib/messages/metadata-fetcher.ts gives me fetchLinkMetadata. And critically, lib/security/ssrf.ts gives me safeFetch and assertUrlAllowed, the SSRF guard the codebase already fought to get right. This work is mostly generalizing the message-only path to also cover docs and list rows, then bolting a real cache behind it so a URL is fetched once and never again.

This is the single most important thing to say to Claude when you hand it this task, so I’ll say it here first: build on safeFetch and assertUrlAllowed. Do not write a new fetch().

I cannot overstate this. Left to its own devices, an LLM asked to “write a crawler that fetches URLs and extracts content” will cheerfully write you a fresh fetch(url) with a nice timeout and some error handling, and it’ll look completely reasonable, and it’ll have reintroduced the server-side request forgery hole this codebase already closed. It won’t check whether the URL resolves to 169.254.169.254 and hands back your cloud metadata. It won’t block the redirect that bounces from a public host to localhost. The existing safeFetch does all of that, and it re-validates on every redirect hop. A brand-new fetcher is a security regression wearing the costume of a feature.

So the instruction isn’t “write a crawler.” The instruction is:

// Crawler activity: the reuse contract, spelled out for the agent.
// 1. detectLinks/extractUrls from lib/messages/link-detector.ts to find the URLs
// 2. hash each URL, check the LinkContent cache, skip if already fetched & unchanged
// 3. fetch ONLY through safeFetch from lib/security/ssrf.ts (never a raw fetch)
// 4. extract readable text mechanically (HTML to text), NO LLM in this path
// 5. upsert into LinkContent keyed by urlHash; dedup means fetch-once-ever
async function crawlUserLinks(userId: string) {
  const urls = await collectUrlsFromMessagesDocsAndRows(userId); // reuse extractUrls
  for (const url of dedupeByHash(urls)) {
    if (await cacheHasFresh(url)) continue;         // incremental: skip known URLs
    const html = await safeFetch(url);              // SSRF guard, NOT a new fetch()
    await upsertLinkContent(url, extractReadableText(html));
  }
}

Note what’s not in there: a language model. HTML-to-readable-text is mechanical. You strip the tags, you pull the article body, you’re done, no tokens spent. An optional cheap-model summary of each page could sit on top later, opt-in, for people who want it, but the base extraction costs nothing per link beyond the fetch. Pair that with URL-hash dedup (a shared link is fetched once, ever, across the whole system) and this becomes the cheapest proposal in the whole plan for what you get out of it. Which is why I like it. It’s mostly plumbing, mostly reuse, and it barely touches an LLM.

Retrieval: The Part That Needs New Ground

The crawl phase gives me the cache. The search-over-my-links phase is what makes it findable. This is “search over my link index and my own docs, lists, and messages, returning cited snippets.”

And I have to be honest that this one needs real new infrastructure, because the vector story doesn’t exist yet: not partially, not disabled behind a flag, not anywhere. So the retrieval phase needs a pgvector extension, an embedding column or table, an embed activity that turns text into vectors, and a search route that ranks by both vector similarity and the existing ILIKE match. Hybrid. It supplements today’s substring search rather than ripping it out. Keyword matching is still the right answer for exact-string lookups, and it needs no model at all.

The cost math here is the reassuring part. Embeddings are orders of magnitude cheaper than text generation: you’re paying to turn text into a vector, not to write prose. You compute each embedding once and cache it, sharing the same cache the crawl phase already built. And because retrieval comes first, any eventual answer-synthesis step runs over a small, already-narrowed set of retrieved snippets instead of dumping your whole corpus into a prompt. Keep the expensive context small and this stays affordable.

The incremental pipeline is what keeps it fresh without a nightly full re-scan: on every content write (new post, edited doc, added row) enqueue a re-embed activity for just that thing. Batch the embeddings for throughput. The index stays current, and you never re-embed the 99% of your corpus that didn’t change.

The Second Lesson: Don’t Let the Agent Touch the Database Directly

The pgvector migration is where the other working-with-Claude lesson lives, and it’s a governance one.

This repo has a strict database workflow, and it’s strict for a reason the CLAUDE.md states plainly: violating it has broken production before. Additive-only. Idempotent SQL. Hand-written migration files applied to both databases. No prisma db push, no prisma migrate dev, no raw DDL fired at the database from a script.

An LLM does not know this in its bones. Ask it to “add a vector column and an embeddings table” and its instinct (a perfectly good instinct in most repos) is to edit the schema and run prisma db push to sync it. In this repo that’s a loaded gun. So the instruction to the agent isn’t just “add the schema.” It’s: route this through the db-migrations workflow. Write an idempotent, additive migration file (CREATE EXTENSION IF NOT EXISTS vectorCREATE TABLE IF NOT EXISTS, the column adds guarded) and do not run db push or migrate dev against the database. The good news is the migration is cleanly additive: there’s no existing vector infra to alter or drop, so it’s all new tables and columns, which is exactly the shape this workflow handles best.

Two guardrails, then, and I’d hand both to the agent up front rather than catch them in review: reuse safeFetch instead of writing a fetcher, and route the schema change through the migration workflow instead of letting it push. Both are cases where the model’s default behavior looks reasonable and is wrong for this codebase. Tell it about the thing that already exists, and tell it not to reinvent it.

The payoff, if I build it, is the thing I wanted in the first place. I type “that article about durable execution I saved somewhere,” and I get it back with the snippet and a link to wherever I stashed it, whether that was a throwaway post or a row buried in a list. My scattered links stop being a graveyard I dig through and turn into something I can actually ask questions of. Subscriber-gated, computed once and cached, and cheap enough that I’d leave it running without watching the bill.

Next up in Part 6, I want to point this same durable-fan-out machinery at bulk work: tag every untagged message, add twenty rows matching a criterion, backfill previews across everything, and do it without re-billing the model for the forty-six items that already succeeded before item forty-seven fell over.

Adron brainstorming and working on InterlinedList.

The Cron That Double-Posts and the Calendar I Actually Want

Part 4 of 8: Durable AI for InterlinedList


Most of this series is me thinking out loud about features that don’t exist yet. This post is different as the bug is real. It’s in the code today. 😬

Scheduled publishing is a Subscriber feature, and so is the cross-posting it rides on. You queue a post, set a time, and a cron fans it out to Bluesky, Mastodon, LinkedIn, and X when the moment arrives. Both sit behind the subscription, so this bug hits paying subscribers. The machinery that runs it has a hole in it I don’t love.

The Cron That Can Post Twice

Walk through what the code does. app/api/cron/publish-scheduled-messages/route.ts runs every minute. It selects every message where scheduledAt <= now, then loops over them. For each one it fans out to whatever platforms the user configured, collects the resulting URLs, and then, as the last step, updates the message row to null out scheduledAt so the message is no longer “due.”

Read that ordering again. The cross-post happens first. The “mark it done” write happens second. No lock. No idempotency key. No transaction spanning the fan-out and the update. Those two operations are just sequential, and hopeful.

So picture the fan-out succeeding. Your post lands on Bluesky and Mastodon. Great. Now the message.update that’s supposed to clear scheduledAt hits a reaped Neon connection, or a transient network blip, or any of the ten boring things that make a database write fail once in a while. The update throws. The message row still has scheduledAt <= now.

One minute later the cron runs again. The message is still due. It fans out again. Same post, second time, to every platform. That’s your double-post.

The code isn’t naive about this: there’s a withPrismaRetry wrapper on that critical update and a comment that literally says “if this fails the message stays due and is re-published.” Someone saw the cliff and put up a guardrail. But a retry is a mitigation, not a fix. It narrows the window without closing it. If the retries exhaust, or the process dies between the fan-out and the update, you’re back on the cliff. And the fan-out itself is best-effort per platform (LinkedIn can fail while Bluesky succeeds, with no compensation), so a re-run doesn’t even cleanly re-do the same thing. It re-does some of it.

This is a slow-moving disaster. It doesn’t fire on every post. It fires on the unlucky ones, occasionally, in production, and it makes your platform look like it can’t be trusted to post your thing exactly once.

The Working-With-Claude Lesson: Point at the Bug Class, Not the Bug

If you take one thing from this post, take this part, not the fix. It’s worth more.

If I open a session and say “fix the double-post in publish-scheduled-messages,” I get a patch that treats the symptom. Maybe it wraps more things in retries. Maybe it adds a published boolean and checks it. Narrow prompt, narrow fix, and I’ve probably left three other copies of the same hazard sitting in the codebase untouched.

So I don’t open it that way. I say: “Audit this scheduled-publish path for concurrency and idempotency hazards.”

That framing is doing real work. It doesn’t presume I already know what’s wrong. It asks the agent to reason about the class of failure: what happens under a partial failure, what happens if two invocations overlap, what’s the ordering of side effects versus state writes, where’s the operation that isn’t idempotent. The double-publish race falls out of that audit as one instance of a general pattern (side effect before commit, no dedup key). And the audit usually surfaces siblings I hadn’t thought to ask about.

This is the two-step I keep coming back to with Claude on anything gnarly. Audit for the class, then delegate the conversion. First get the agent to characterize the whole shape of the problem. Then, once you both understand it, hand over the mechanical rewrite. Collapse those into one “fix it” prompt and you’ll get a bandage every time.

Most of the value is in the audit. The conversion is mostly typing, and Claude is very good at the typing once you’ve done the thinking with it.

The Fix: A Schedule, a Workflow ID, and a Saga

So this is the conversion I’d delegate. It’s a proposal. None of it is built yet.

Replace the every-minute cron with a Temporal Schedule that starts one workflow per due message. The whole trick is what you use for the workflow ID.

// Proposed: the message id IS the workflow id.
// Temporal refuses to start a second workflow with an id that's
// already running or already completed. That's the dedup.
await client.workflow.start(publishScheduledMessage, {
  workflowId: `publish-message:${message.id}`,
  taskQueue: "publishing",
  args: [{ messageId: message.id }],
});

Temporal deduplicates by workflow ID. If a workflow with publish-message:abc123 has already run (or is running right now), a second start with that same ID is rejected or returns the existing handle, depending on the reuse policy you pick. The message cannot be published twice, because you cannot start its publish workflow twice. The race doesn’t get narrower. It closes. There’s no second racer allowed on the track, so there’s nothing left to race.

That’s a different guarantee than “we retried hard enough that it usually works.” One is a hope with good odds. The other is impossible by construction, and I’ll take impossible.

Then model the fan-out as a saga instead of a hopeful loop. One activity per platform: postToBlueskypostToMastodonpostToLinkedInpostToTwitter, the exact post-status.ts functions the cron already calls, plus the same splitTextForPlatform and resolveLinkedInTarget logic. Each activity retries independently with backoff, and each one’s success is durably recorded before the next runs. If LinkedIn fails after Bluesky succeeded, the workflow knows Bluesky is done and won’t re-do it. It records the half-failure coherently and runs compensation, instead of silently re-posting the whole set on a blind re-run.

And the thing that makes this an easy call: publishing uses zero LLM. No model in this loop at all. No tokens, no inference cost, nothing to govern with a budget. It’s pure durability engineering, a straight win against a present-day production hazard with no ongoing cost attached. If anything in this series jumps the queue, it’s this.

The Calendar I Actually Want

Once publishing is durable, the thing I’ve wanted for ages becomes buildable. This part is squarely future, gated behind AI assistance that’s still marked Coming Soon.

I want to type “give me a month of posts about distributed systems, three a week” and get a filled calendar back. Not a wizard. Not twelve separate button clicks. A month.

The cheap way to build that leans on batching. One LLM call produces all N drafts at once, and batching many items into a single prompt is dramatically cheaper than N separate calls (the cost thesis that runs through this whole series). The workflow takes those drafts, schedules them across the month, and fills the gaps. Then, to keep it cheap, you can reject or regenerate an individual slot. Don’t like Thursday’s post? Regenerate just that one. Only the rejected slot re-bills. You never repay for the twelve you kept.

The same shape covers scheduled digests: a Temporal Schedule that drafts you a weekly summary doc from the week’s messages. But put a cheap guard before the model runs. Did this user post anything this week? If not, skip the LLM entirely. You never pay to generate an empty digest for a quiet week. The guard is a database count. The model is the expensive part. You gate the expensive part behind the cheap check, which is the move I keep making all through this series: spend tokens last, and only when there’s something worth spending them on.

The calendar is the feature I daydream about. The double-publish fix is the one I’d ship first, because it’s a real hole in production right now and closing it costs nothing but the work.

Part 5 goes hunting through everything you’ve saved: the links buried in your messages, docs, and list rows, and how I’d crawl and cache them so “where did I save that article?” finally has an answer.

Adron brainstorming and working on InterlinedList.