The first thing most people put in a new repository is code. That is fine. It is also incomplete.
When I start a real project these days, something I expect Cursor, Claude, Codex, and I to grind on together for more than an afternoon, I create a notes repo early. Sometimes it sits beside the product repo. Sometimes it lives inside the product repo as a deliberate notes/ or docs/project/ tree. Either way, the job is the same: give the humans and the agents one durable place to put the stuff that does not belong in source files but absolutely belongs in the work.
This is not documentation for show. It is shared working memory.
Why a notes repo, not a pile of chat
Chat is ephemeral. Agent transcripts are useful, but they are not a system of record. Plans get buried. Decisions get restated wrong three sessions later. Somebody new (human or model) shows up and has to rediscover who owns what, what “done” means, and which external systems are in play.
A notes repo fixes that by making the context something you can open and search. Cursor can open it. Claude can read it. Codex can search it. I can edit it when the plan changes. Everyone is looking at the same markdown, not reconstructing the project from memory and half-remembered Slack threads.
I treat it as the base layer under the code. The product repo holds the build. The notes repo holds the thinking that keeps the build pointed in the right direction.
What actually goes in it
I keep the structure boring on purpose. Fancy wikis rot. Flat, named markdown files get used.
Project brief. What we are building, who it is for, what it is not. One page. If you cannot say it in a page, you do not have a project yet. You have a mood.
Current plan. The active plan, not a museum of every plan that ever existed. Short enough to approve in one breath. Specific enough that “yes” means something. When the plan changes, rewrite it. Do not append a novel of abandoned approaches unless those approaches teach a real constraint.
Decisions. Lightweight ADRs, or just dated notes: we chose X because Y, and Z is explicitly out of scope. Agents love to re-litigate settled choices. Write the settlement down.
Team map. Who is who. Humans, roles, ownership. Which agent lanes exist if you have specialized agents. Who reviews security. Who can approve schema changes. “The team” includes the tools now, so name them and their jobs.
Glossary. Product words, domain words, the three acronyms everybody uses differently. Cheap insurance against confident mistakes.
Working notes. Scratch space for the current thread of work: open questions, links to tickets, sketches, “we tried this and it failed because…”. This is the joint notebook Cursor, Claude, Codex, and I write into while we work.
Pointers out. Links to the product repos, design files, staging URLs, runbooks, and the environment/setup notes. The notes repo should know where the rest of the world lives without trying to duplicate it.
I do not put secrets here. I put the names of secrets and where they are supposed to live. That distinction matters.
How I use it with agents
The pattern is simple: before an agent starts implementing, it reads the brief, the current plan, and the relevant decision notes. After a meaningful chunk of work, it updates the working notes or the plan so the next session does not start from zero.
That pairs cleanly with the ask-plan-confirm habit I already use. The notes repo is where the approved plan lives between sessions. The agent does not get to invent a new goal because the chat scrolled away.
A few practical rules that hold up:
One current plan file. Archive old plans if you must, but do not make the agent guess which file is live.
Prefer short files with clear names over one giant NOTES.md.
Write for the next reader who was not in the room. That reader might be you in two weeks, or an agent with a fresh context window.
Update notes as part of the work, not as a cleanup chore after merge. If it is optional, it will not happen.
What this saves
It saves re-explaining the project every morning. It saves the “wait, who owns auth?” loop. It saves agents from optimizing the wrong goal because the real goal lived in a Slack thread from Thursday.
It also saves me from being the only continuity process on the team. Continuity is a file. Version it. Diff it. Argue with it in a pull request if the decision is big enough.
Start smaller than you think
You do not need a knowledge management platform. You need a repo, a README that says what the repo is for, and a handful of markdown files that stay honest.
Create it when the project becomes real. Keep it next to the code in your mental model. Make every agent treat it as required reading before they touch the product tree.
Code is the artifact. Notes are the shared brain that keeps the artifact from wandering off.
There is a version of a feature toggle that looks wonderfully simple in a pull request:
if(features.newThing){
doNewThing();
}
Then newThing turns out to be a data pipeline, a shipping promise, or a screen people have already started using. Now the switch has an owner, a scope, a failure mode, and a date when we ought to remove it. The if statement is the least interesting part.
I want to work through three concrete examples. The first moves a flat-file order feed through Databricks, then cuts a tenant over to normalized PostgreSQL behind a data API. The second turns on expedited shipping, with the same decision implemented in TypeScript and Go. The third releases a saved-filters interface in React and SwiftUI. Each is a different kind of switch, and each can surprise you if you treat it as a Boolean sprinkled through the codebase.
The examples are deliberately small enough to read, but the boundaries are real: immutable inputs, idempotency, tenant scoping, stable rollout decisions, and the difference between hiding a button and actually controlling a capability. Let’s get into it.
1. The order feed: Databricks today, PostgreSQL data API tomorrow
Imagine a partner dropping one immutable CSV file per batch into object storage. Each row is an item on an order:
The existing path loads the file into a Databricks Delta bronze table, then produces a current-order table for queries. The proposed path reads that same object, validates it, and sends a complete batch to a private data API. The API writes three normalized PostgreSQL tables: customers, orders, and order_items.
immutable CSV in object storage
|
batch worker
|
tenant route snapshot
/ \
Databricks PostgreSQL data API
COPY INTO validate + transaction
bronze/curated customers/orders/items
\ /
read adapter for the tenant
The switch is a tenant route, not a random choice made separately for each row. For a given batch, resolve the route once and put it in the batch log. Changing the route while a file is half processed would create an impressively confusing incident.
The route and the file contract
I keep the control-plane data separate from the pipeline code. In this example the configuration is a checked, versioned snapshot supplied to the worker; in production it might come from a flag service. The important bit is that the worker receives one decision and uses it for the whole operation.
// pipeline/route.ts
exporttypePipelinePath="databricks"|"postgres";
exportinterfaceRouteSnapshot{
version:number;
defaultPath:PipelinePath;
tenants:Record<string,PipelinePath>;
}
exportinterfaceBatchRef{
tenantId:string;
batchId:string;
fileName:string;// a basename under the tenant's immutable S3 prefix
sha256:string;// digest of the file's bytes, recorded when uploaded
The file key is a basename by design. The worker constructs the object path from a fixed bucket and tenant prefix. That keeps a caller from turning a batch submission into “please read whatever URL I hand you.” The SHA-256 is about replay identity: batch-42 with new bytes is an error, not a cute way to overwrite history.
The existing Databricks path
Here is the setup on the lakehouse side. The external location and warehouse already have access to the bucket. I’m showing Databricks SQL because COPY INTO is a good fit for an incremental feed of files, and the loaded-file tracking makes retries tractable. If your feed is millions of files, Databricks points you toward Auto Loader instead.
The TypeScript adapter submits a SQL statement to a warehouse and waits for completion. For this example every query returns either no rows or a small lookup result; large query results need the API’s external-links disposition and a separate paging design.
There is a deliberate simplification here: the merge scans bronze and models upserts, not item deletion. If a later snapshot can remove an item, the source contract needs tombstones or a replace-whole-order operation. No toggle solves an undefined deletion contract. Also, in a real lakehouse I would key these tables by tenant, or put each tenant in its own governed schema; the sample’s order_id must be globally unique for the shown merge.
The PostgreSQL path and its data API
On the new path, the database is an implementation detail of the data API. The worker never gets a PostgreSQL connection string. A private, authenticated service receives a complete batch; the service owns validation, idempotency, and the transaction.
The flat file repeats customer names and order IDs. The relational model stores each customer and order once, then each item under that order. This normalization is useful for operational queries and constraints; it is not a claim that PostgreSQL is automatically the better analytics engine. The switch is about the workload we actually need to serve.
The worker downloads the immutable object and parses it. This uses @aws-sdk/client-s3 and csv-parse/sync. I cap the file at 10,000 rows here so the data API can handle one transaction; larger feeds should use bounded chunks with a manifest and a stronger commit protocol.
if(!response.ok)thrownewError(`Data API rejected batch: ${response.status}`);
}
The API side is where the transaction belongs. zod checks the wire shape; PostgreSQL constraints still carry the final integrity guarantee. The gateway authenticates the service token and supplies the tenant identity; it must verify that the tenant in the request body matches that identity. I’m leaving gateway wiring out of this excerpt, but I would not expose this route to the public internet as an unauthenticated import endpoint.
Notice the batch marker is inserted inside the same transaction as the rows. If the transaction fails, the marker disappears too. Retrying the file then does useful work instead of being mistaken for a success. The FOR UPDATE also serializes replacement of one order’s items. For exact ties in event_at, the upstream contract should supply a monotonically increasing revision; timestamps alone cannot tell which different snapshot wins.
The read half of the data API keeps PostgreSQL behind the boundary too:
app.get<{Params:{orderId:string}}>(
"/v1/orders/:orderId",
async(request,reply)=>{
// tenantId comes from authenticated gateway context, never a query param.
WHERE tenant_id = $1 AND order_id = $2 ORDER BY sku`,
[tenantId,request.params.orderId],
);
return{...order.rows[0],items:items.rows};
},
);
That x-verified-tenant-id header is only safe if a trusted gateway strips any client-supplied copy and injects its own value. In a direct deployment, put authentication and tenant extraction in a Fastify hook instead. The point is to show where tenant ownership is enforced, because “the new API is private” is not an authorization strategy.
The consumer needs the same route decision. Here the Databricks query uses a named parameter and the PostgreSQL side uses the data API. The adapter presents one order shape to its caller. This example assumes the order ID is globally unique in Databricks; if it is only unique per tenant, add tenant_id to the Delta tables and both predicates.
My cutover order would be: add the new API and schema, backfill it from immutable files, compare order counts and sampled contents, run both paths in a controlled shadow period, then route one tenant’s writes and reads to PostgreSQL. Measure lag, API errors, rejected rows, and mismatches by tenant. Keep the original feed while rollback is needed. If writes have happened only on the new path, flipping the read switch back to Databricks can show stale data; rollback needs replay to the old path or a freeze until it catches up. That’s the part the tidy if statement never tells you.
2. Expedited shipping: a feature flag with a bill attached
Here’s a second example: turn on an expedited-shipping offer for a percentage of accounts. The new path does not merely paint a badge. It changes the checkout quote, so we need one stable decision per order and a record of the rule that produced the price.
The rule is: the account is included in the rollout, the destination is in the supported region, the cart subtotal is at least 7,500 cents, and the feature has not been killed globally. I use a stable FNV-1a hash of accountId for the rollout. It is small and portable across TypeScript and Go; it is not a security primitive. A production flag service can own the assignment instead, provided both stacks read the same assignment.
// At checkout creation, persist the quoted cents and decision with the order.
// At payment confirmation, charge that stored quote after its normal expiry
// and inventory checks. Do not ask the flag again halfway through checkout.
This is where teams often reach for Math.random() and accidentally give the same customer a different offer on every refresh. The bucket makes the rollout stable. The saved decision makes an in-progress checkout stable even if the operator moves from 10% to 20% while a customer is entering their card details.
The same boundary in Go
If another service computes a quote in Go, it must agree on byte encoding, hash, threshold, region names, and cents. This uses only the standard library.
packageshipping
import(
"errors"
"hash/fnv"
)
typeFlagstruct{
Enabledbool
KillSwitchbool
RolloutPercentint
Revisionstring
}
typeInputstruct{
AccountIDstring
SubtotalCentsint64
Regionstring
}
typeDecisionstruct{
Offeredbool
FlagRevisionstring
Reasonstring
}
typeQuotestruct{
StandardCentsint64
ExpeditedCents*int64
DecisionDecision
}
funcBucket(accountIDstring)int{
h:=fnv.New32a()
_,_=h.Write([]byte(accountID))// UTF-8 bytes, as in TextEncoder
returnint(h.Sum32()%100)
}
funcQuoteShipping(inInput,flagFlag)(Quote,error){
ifin.SubtotalCents<0||flag.RolloutPercent<0||
flag.RolloutPercent>100{
returnQuote{},errors.New("invalid quote input or flag configuration")
I would put a shared set of fixture inputs in both test suites: account IDs with ASCII and non-ASCII characters, rollout at 0 and 100, subtotal at 7,499 and 7,500 cents, each region, and the kill switch. The tests should assert that TypeScript and Go assign the same account to the same bucket. This is one of those boring cross-language details that becomes a very exciting checkout bug if you skip it.
The rollout sequence is straightforward: ship both implementations dark, compare decisions against fixtures, enable internal accounts, then 1%, 10%, and onward while watching quote errors, conversion, fulfillment capacity, and customer support reports. The kill switch stops new offers. Existing orders retain their stored price and promise; changing those would be a different business operation. When the feature is permanent, remove the rollout branch and leave the eligibility rule as normal checkout code.
3. Saved filters: the interface switch and the user’s own toggle
The third example is a saved-filters panel for an order list. There are two switches here that people tend to conflate. The feature flag says whether this account has access to saved filters. The user preference says whether the available panel is currently shown. If the feature flag is off, the preference cannot manufacture access.
The server returns a capability document after authentication:
{
"savedFilters":true,
"revision":"ui-2026-09-24-3"
}
The endpoint that creates or lists saved filters must evaluate the account’s capability again. A React component that omits a button is a nicer screen, not an access control check. The same applies on iOS.
TypeScript and React
The browser fetches the capability, treats unknown as off, and lets the person hide or show the panel with a visible toggle. The preference is local to the browser in this version; if we want it to follow a user across devices, that becomes a server-side preference with its own API and migration.
The button passes a filter ID, not arbitrary SQL from storage. The order list asks its own API to apply that ID under the current account. If the flag goes off while this page is open, a fresh capability fetch on navigation or a short-lived cache will remove the panel; the API check shuts off server access immediately.
The same feature in SwiftUI
On iOS, @AppStorage is the user preference. The capability still comes from the server. A small model loads it and, only when allowed and opened, loads the filters.
importSwiftUI
structCapabilities:Decodable{
letsavedFilters:Bool
letrevision:String
}
structSavedFilter:Decodable,Identifiable{
letid:String
letname:String
letquery:String
}
@MainActor
finalclassSavedFiltersModel:ObservableObject{
@Publishedprivate(set)varcapability:Capabilities?
@Publishedprivate(set)varfilters:[SavedFilter]=[]
@Publishedprivate(set)varerrorMessage:String?
// apiBase and URLSession are injected so previews/tests can use fixtures.
The app’s authenticated URLSession would carry the user’s credentials; the sample leaves that wiring to the host app. When someone taps a filter, the host sends the ID to the order-list API rather than trusting the locally decoded query. I would test both clients with the same capability responses: on, off, failed request, and revocation after the screen has loaded. I would also test the API endpoint directly with the feature disabled. The server is the actual gate.
The switch has a lifecycle
Across these examples, I would keep a little record for every flag: owner, default, scope, rollout plan, telemetry, rollback behavior, and removal date. The pipeline route is scoped to a tenant and batch. The shipping offer is scoped to a stable account cohort and then frozen into an order quote. The interface flag is scoped to an authenticated account, while the visible on/off control is a separate user preference.
That distinction is the useful mental model. A toggle switch is an operational decision point, and the rest of the system has to agree on what was decided. Give it one boundary, persist decisions when they affect money or durable data, measure the new path, and remove the temporary branch after the migration is over. Otherwise the switch becomes one more permanent mystery in the codebase, which is a lousy reward for trying to ship safely.
Getting a coding agent productive is less about the prompt and more about whether the machine in front of it can actually build, run, and reach the systems the work depends on.
Cursor, Claude, Codex, and the rest are fast when the local world is already honest: dependencies install, the app boots, tests can run, and the external surfaces (GitHub, GitLab, Slack, Outlook, Teams, whatever is in play) are reachable through clear, permissioned paths. When those pieces are missing, the agent spends its tokens rediscovering your laptop instead of doing the job.
This post is about making that setup cheap and repeatable.
The real prerequisite is a bootable story
Every repo should answer, in files the agent can read, a small set of questions:
What do I need installed on this machine?
How do I get from clone to a running local system?
Which services does this project talk to, and which of those am I expected to use locally?
Where do secrets live, and what is not allowed?
How do I know the setup worked?
If those answers only exist in your head, the agent will invent something almost right. Almost right is expensive.
I keep this in the product repo as first-class docs and scripts: README, CONTRIBUTING, docs/setup, scripts/bootstrap, .env.example. When the work spans more than one codebase, I also mirror the “where the rest of the world lives” map in the project notes repo.
Make setup mechanical
Agents are excellent at following a checklist. They are mediocre at inferring your undocumented brew taps and tribal knowledge.
What works well right now:
A single bootstrap path. One script or documented sequence: install language runtimes, install dependencies, copy env templates, start local dependencies, run a smoke check. Prefer boring and explicit over clever.
Pinned, discoverable tooling. Version files (.nvmrc, .node-version, mise.toml, asdf configs, rust-toolchain, etc.) beat README poetry. The agent should be able to detect the toolchain without asking you which Node major you meant last quarter.
.env.example that matches reality. Every required variable named. No secret values. Short comments for where to get each one. If a variable is only for production, say so.
Smoke tests for the environment. A command that proves the local stack is alive: npm run doctor, make verify, a compose healthcheck, a migration status call. Green means “safe to start feature work.” Red means “fix the machine, not the feature.”
Dev containers or explicit host setup. Pick one and document it. Either the agent works inside a known container, or it works on the host with a known bootstrap. Mixing both without saying which is canonical creates two slightly broken worlds.
This is the same instinct as my old dev-setup-osx habit, updated for a world where the “new developer” might be an agent that showed up thirty seconds ago.
Tell the agent where the working systems are
Local build is only half the map. Most real work touches other systems: source hosts, chat, mail, issue trackers, cloud consoles, internal APIs.
Write down the topology.
Product repos and their remotes (GitHub, GitLab, both, mirrors).
Which environments exist: local, staging, production, preview apps.
Which human collaboration surfaces matter: Slack channels, Teams teams, Outlook lists, Linear/Jira projects.
Which of those the agent is allowed to read or write.
How authentication is supposed to happen for each.
Do not make the agent guess that “the deploy” means GitHub Actions in one repo and a GitLab pipeline in another. Put the pointers in markdown next to the code or in the notes repo. Ambiguity here turns into the wrong PR opened against the wrong remote, or a “fix” that never lands where humans look.
MCP is how agents reach the rest of the desk
Model Context Protocol servers are the practical bridge between the coding agent and the tools already on your desk. Used well, they turn “go check the thread / issue / inbox / pipeline” into a first-class action instead of a copy-paste scavenger hunt.
The useful pattern is not “connect everything.” It is “connect the systems this project actually depends on, with the least privilege that still helps.”
A sane MCP layout for project work often includes:
Source control: GitHub and/or GitLab for issues, PRs, checks, and releases.
Chat: Slack or Teams for the channels where decisions and unblockers live.
Mail/calendar: Outlook or similar when the work is genuinely gated on threads or meetings, not because it is fun to give an agent your inbox.
Project tracking: whatever holds the tickets, if that is not already the git host.
Docs and runbooks: if they live outside the repo and agents keep asking for them.
Wire these at the user or project level in Cursor (and equivalents elsewhere), then document in the repo which MCP servers are expected for this project and what they are for. An agent that knows “Teams is available for the eng channel, GitHub is available for PRs, Outlook is not in scope for this repo” wastes less time and takes fewer weird actions.
Authentication matters. Prefer the product’s normal OAuth/device flows. Keep tokens out of the notes repo and out of prompts. If a server needs auth, say so in setup docs and stop there. Do not paste credentials into markdown “for convenience.”
Boundaries beat cleverness
An agent with broad access and no rules will eventually do something technically impressive and socially awful: comment in the wrong channel, open a PR against the wrong fork, or dig through mail that was never part of the task.
Give it clear limits:
Read-only by default where write access is not required.
Explicit allow-lists of repos, channels, and projects.
Repo instructions that say when to use MCP versus when to stay local.
The same ask-plan-confirm gate you use for code changes when the action leaves the laptop (posting, labeling, merging, emailing).
Local environment setup gets the agent building. MCP gets the agent collaborating. Boundaries keep both from becoming a mess you have to unwind.
A minimal checklist I actually use
When I stand up a project for agent-assisted work, I want at least this:
Clone, bootstrap, and smoke test documented and scripted.
Toolchain versions pinned in-repo.
.env.example complete; real secrets elsewhere.
Notes/brief that name the remotes, environments, and collaboration surfaces.
MCP servers configured for the systems in play, with purpose notes in the project docs.
Clear write/read expectations for each integration.
A first prompt that points the agent at setup docs before feature work.
None of that is fancy. All of it is what makes Cursor, Claude, Codex, and friends look brilliant on day one instead of lost in your PATH.
The goal is simple: when an agent sits down at the project, the local world boots, the surrounding systems are findable, and the rules of engagement are already written down.
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.
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.
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.
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:
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-webhooks, npm 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:
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.
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:
A 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.
A 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.
A 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!
You must be logged in to post a comment.