I have been building a fishing app for 58 days.
In that time the repository has taken 374 merged pull requests. I am one person. I did not write most of that code — two AI agents did, working from two different instruction files, in one repository, and a CI test keeps the two files honest with each other.
This post is about that setup: why I ended up with two agents instead of one, the specific way it broke, and the twenty lines of test code that stopped it breaking again.
1. The setup
The app is World Fishing Map. You photograph a fish, an AI identifies the species, you approve it, and the catch goes onto a world map. The interesting part is not the identification — it is that the app tries to verify the catches it records, using EXIF and GPS cross-checks, perceptual hashing against nearby photos, a water-body lookup, and a hash-chained append-only ledger. Trustworthy records earn points. Your tier decides how precisely you can see other anglers' spots.
Flutter on the front, FastAPI and PostGIS on the back, Firebase for auth.
Two humans work on it: me, and a collaborator I will call Mao. And here is the part people find strange:
- I use Claude Code.It reads-
CLAUDE.mdautomatically. - Mao uses ChatGPT Codex.It reads-
AGENTS.mdautomatically.
We are not using the same tool. We never were. Mao was already comfortable in Codex; I was already comfortable in Claude Code. Standardising on one would have meant one of us working in a tool we were slower in, for the sake of tidiness.
So instead of standardising the tool, I standardised the source of truth.
2. The problem nobody warns you about
Each agent reads its own file. That sounds fine until you notice what it implies: your project has two instruction manuals, and nothing forces them to agree.
This is worse than it sounds. An instruction file for a coding agent is not documentation. It is closer to configuration. It contains statements like "never grant points from this code path" and "this ledger is append-only, do not add an UPDATE." When the two files drift, you do not get a stale README. You get two agents building to two different specs, confidently, at six pull requests a day.
I did not reason my way to this. I found out the way everyone finds out.
Early on I split my single large CLAUDE.md into a root file plus per-directory files (api/, app/, web/). Sensible. Then, to get Codex up to speed, someone created AGENTS.md by copying the pre-split CLAUDE.md.
It worked immediately, which is the dangerous part. For a while, Codex had a complete and coherent set of instructions. They were just the old ones. Every subsequent change I made to CLAUDE.md widened the gap silently, because nothing in the system knew the two files were supposed to be related.
3. The rule: one original, and pointers
The fix was to stop treating them as two manuals.
CLAUDE.md is the single source of truth. AGENTS.md is a pointer. It is not allowed to contain policy.
Root AGENTS.md now opens like this (translated):
The source of truth for instructions is
CLAUDE.md. This file does not duplicate its content. Duplication guarantees that one copy goes stale — and in fact anAGENTS.mdthat was copied from a pre-splitCLAUDE.mdis exactly how that happened. This file holds only "where to read" and Codex-side operating rules.
It also has to do one job that is easy to miss: Codex does not automatically open CLAUDE.md files in subdirectories. Claude Code does. So AGENTS.md explicitly tells Codex to go open api/CLAUDE.md before touching api/. The asymmetry between the two tools is itself a thing the instruction file has to encode.
4. Twenty lines of CI that keep it true
A rule you cannot enforce is a wish. So the relationship between the files is a test that runs on every pull request.
```
POINTER_DIRS = ("api", "app", "web")
Guard against a pointer growing into a second source of truth.
MAX_POINTER_LINES = 60
MAX_ROOT_LINES = 200
def test_root_agents_md_points_to_claude_md() -> None:
text = _read(REPO_ROOT / "AGENTS.md")
assert "CLAUDE.md" in text
for name in POINTER_DIRS:
assert f"{name}/CLAUDE.md" in text, (
"Root AGENTS.md is missing the pointer to "
f"{name}/CLAUDE.md. Codex does not read subdirectory "
"CLAUDE.md files automatically, so this is mandatory."
)
assert len(text.splitlines()) <= MAX_ROOT_LINES
```
Three things it checks:
- The pointers exist.If-
AGENTS.mdstops mentioning-api/CLAUDE.md, CI fails. - The pointers stay thin.A line-count ceiling — 60 lines for directory pointers, 200 for the root. This is the important one. It does not test correctness; it tests- size. The failure mode I actually hit was not "the pointer is wrong," it was "the pointer quietly grew until it was a second manual." A line count catches that, and nothing else I could think of does.
web/AGENTS.mdis blocked from public delivery. My-web/directory is deployed by Vercel with no build step, so anything I put there is published to the world. I learned this when I found my own internal-web/CLAUDE.mdwas live on the public internet. Now that path is blocked in two places, and CI checks both.
The line-count ceiling is the trick I would actually recommend to someone else. Everyone writes docs tests that check for presence. Almost nobody writes one that checks for absence of bloat, and bloat is how a pointer becomes a fork.
5. Preservation anchors
The second thing I would steal from this project is a concept I call preservation anchors.
An AI agent asked to refactor something will happily produce a cleaner, smaller, working version that has quietly dropped a feature. It is not lying to you. It genuinely does not know that the thing it removed was load-bearing.
So CLAUDE.md has a section that is nothing but a list of things that must not change, each with a one-line reason and a pointer to where the detail lives. Fraud-detection scoring formulas. The append-only triggers on the ledgers. The rule that consumption entries must carry a NULL catch ID, because the fraud clawback reconstructs awarded amounts by summing per-catch deltas — break that and a clawback silently under-refunds.
And then, the line that does the real work:
If a change touches a preservation anchor, the tests will fail.
When they fail, do not fix the test — first suspect that the change broke the anchor.
That sentence exists because the natural instinct of a coding agent staring at a red test is to make the test green. Which it is very, very good at.
6. What the AI actually got wrong
The fun part of building in public is that I get to tell you about the failures, and they were not the ones I expected. I expected bad architecture. I got bad facts.
The app ships a species encyclopedia — currently 2,940 species, each with an illustration and a written description. Almost all of that content was AI-generated and machine-cross-referenced against a taxonomic database. Three examples:
- 410 species carried an English name that was not that species' name.The upstream source lists vernacular names with no "preferred" flag, so the pipeline took whichever sorted first. Mostly that produced an obscure regional name for the right fish; sometimes it produced a different animal outright — one shark was labelled "School Shark", which belongs to a fish in a different family. And because the descriptions were generated- beforethe names were fixed, with the name fed into the prompt,- 96% of the corrected species had the wrong name embedded in paragraphs of confident prose about the wrong fish.
- A fish with 1,030 occurrence records, all in one corner of Australia, was described as widely distributed across the Indo-Pacific.One generator gets the occurrence data as evidence; the description generator gets names and taxonomy and is asked to write about range anyway. Another species had its family misidentified in the text while the correct family sat in a column rendered on the same page — that page contradicted itself in production for twelve days.
- Flatfish were drawn facing the wrong way.Which side a flounder's eyes are on is a real taxonomic property. The illustration generator did not know that. I audited all 53 species one at a time and mirrored 20 — and found that two of the ones I left alone are pictures of an entirely different fish.
None of these were caught by a test, because none of them are the kind of thing you think to write a test for. They were caught by a human looking at fish.
That is the honest lesson of eight weeks at six pull requests a day: the agents made me fast at building, and they did not make me fast at being right. The bottleneck moved. It did not disappear.
7. What I would steal from this
If you are running more than one coding agent on one codebase:
- Pick one source of truth and make the others pointers.Not "keep them in sync" — that is a promise you will break.
- Test the pointers for size, not just presence.Bloat is the actual failure mode.
- Write down what must never be deleted, and say so in the agent's own instruction file.Then tell it explicitly not to fix the failing test.
- Assume the facts are wrong, not the code.Your agent will produce structurally excellent code full of confidently incorrect content. Budget human review for content, not syntax.
I am shipping this app during RevenueCat Shipaton 2026, and writing up what happens as it happens. The next post is about what it took to add a paywall to an app whose entire premise is that its most valuable feature is not for sale.
Building in public as @WorldFishingMap on X. Follow along.