Since January 7th I have shipped 458 commits across four repositories of a single product and cut 42 releases. The product is a London Underground app: an iOS client (Swift, TCA), an Android client (Kotlin, Compose), a web version (React, WebGL2) and a Go backend. Thirteen App Store releases, ten on Google Play, eleven web, eight backend.
No, the app was not written by an AI. Product decisions, architecture and priorities stayed with me. All of the design was done by my girlfriend: the visual system, the UX, and the tube map itself, which she drew by hand. Claude Code agents took over the least rewarding part of cross-platform work, which is carrying one decision onto four stacks without losing the details on the way.
None of this is free, and some of the side effects are visible in the product itself. What follows is how the team is set up, three ways parallel agent work breaks in practice, and what it costs.

What the app does, and why that is hard
Tube Map plans journeys across the London Underground and renders the network diagram. Three properties create all of the engineering difficulty.
It works underground. That is the primary scenario, not a nice extra: someone pulls out a phone on a platform where there is no signal and never will be. So the whole map, the whole graph of stations, interchanges and lines lives on the device, and routes are computed locally. We use the network when it is there, because the data is fresher and accounts for current disruptions, but losing signal changes only the quality of the answer, never the behaviour of the app.
The map is rendered on the GPU. A metro map is a diagram, not a territory: it has its own geometry, its own angles, its own label placement, none of it tied to real coordinates. Off-the-shelf mapping engines are the wrong tool, so we render our own vector format directly through Metal on iOS, OpenGL ES on Android and WebGL2 in the browser. The user gets smooth zoom and labels that stay legible at any scale.
This is worth pausing on, because it is the most underestimated part of the work. A person drew that diagram, from the first line to the last label. A metro map is made of judgements made by eye: where to straighten a line and where to let it bend, how to separate four lines through one interchange so all four remain readable, where to put the label of a long station name so it does not collide with the branch next to it, what spacing to leave so the dense centre does not turn to mush. Every one of those is a trade between accuracy and legibility, and none of them has a correct answer. There is only the answer that works when someone glances at a screen in a tunnel for ten seconds.
You cannot generate that. A model will produce something that looks like a metro map: lines at the right angles, station circles, labels alongside. Then you look closely and the interchanges are wrong, a label sits on top of a line, and the centre is packed so tightly you cannot hit a station with your thumb. It is not a generation problem. It is a thousand small judgements, each of which holds the rest of the drawing in mind.
The shortest path is rarely the useful one. A metro passenger is not optimising a single number. One person wants the fastest possible trip, another does not want three interchanges with a suitcase, a third does not want to walk between stations in the rain. So we show several routes with different characters: one with walking legs, a hybrid, and rail only.
All three properties have to behave identically on iOS, Android and web. Someone plans a journey on their phone, opens the site from the office later, and has to see the same route with the same interchanges, not two different opinions from one app.
We pinned that down with a hash. The route recompute identifier is derived from a token list of the form <stationId>|<lineId>, and for Oxford Circus to Bond Street on the Central line that hash is df3e345063b5ffe5 on all three clients. A regression test on each one asserts that exact value. If a single character drifts, the logic has drifted somewhere, and finding that out in CI beats hearing about it from a user in a tunnel.
Holding a contract like that while doing the platforms one after another is close to impossible. You write iOS, pick up Android a week later, and half the decisions made along the way have evaporated: why the token order is what it is, why a walking transfer does not count as an interchange, where a specific timeout came from. You do not remember, so you reconstruct it from the code, and code answers what, not why. You reach web on the third pass, and a small divergence has become a structural one.
How the team is set up
Six roles, each in its own context window, sharing a task list and able to message each other.

Four platform workers: iOS, Android, web, backend. Each owns exactly one repository and never touches another’s files.
A QA role that checks not whether it works but whether it works the same. It runs the test suites on every platform, compares public interfaces, and hunts for behavioural differences. Its job is to find the place where three implementations diverged, not to confirm that everything is fine.
An analytics role that owns the event contract. Event names, their parameters, the order they fire in: this is as much a cross-platform API as the route format, except that it fails more quietly. A funnel starts lying, and you find out a month later while trying to work out why conversion dropped.
Those last two are tuned for pedantry deliberately. Their job is to object, because that is the only defence against the main risk of the approach: four workers, each individually correct, jointly assembling a product that behaves differently on three platforms.
Pedantry has a limit, though, and the limit is economic. Take any pair of screenshots of the same screen from two platforms and look closely: the padding will differ, by two points here and four there, a card will be slightly wider, a line will wrap in a different place. QA does not catch that, because it checks behaviour and the set of elements, not pixels. You could of course issue an instruction that three implementations must be pixel-perfect against each other and put the agents in a comparison loop, but the token cost of that loop is unreasonable: every round means re-reading the layout on three stacks, re-recording snapshots and comparing again, all for two points of padding no user will ever notice. We hold the element set, the behaviour and the data format identical. Padding lives inside a tolerance.


What this costs in the product itself
One platform inevitably becomes canonical. For us that is iOS: decisions are made there first, they get shaken out there, and Android and web receive them as ports. It works, but the Android project stops being entirely Android because of it. It inherits its module structure, its navigation shape and its state decomposition from the world of Swift and TCA rather than from how Compose is normally written. A developer coming from pure Android will look at that code and say it is written with an accent.
Tablets are similar. Our tablet layouts largely repeat the desktop web version: the same screen zones, the same side panel, the same logic for what appears where. We adjust the UX where it matters for thumbs instead of a cursor, but the overall composition arrives from web. Designing from the tablet up would have produced something else.
In exchange we get a product that behaves predictably the same on four platforms, and an absence of the drift where platforms diverge over a year until a shared feature can no longer be described in one document. The price is platform idiom, and the decisions that only appear when you design for one form factor with nothing else in view.
For this kind of product the trade is worth it: a metro map should look and work the same everywhere, and people move between platforms within a single day. For an app with a strong platform character I would not do it this way.
The naive first version
I started with the obvious thing: background subtasks inside one session. Gave each one a repository, described the feature, waited.
I got duplication and divergence. Background subtasks have no shared task list and no way to talk to each other. Each lives in its own context and knows nothing about its neighbours. When the iOS worker settles a detail of an analytics event along the way, deciding for instance that the field is error_kind rather than errorKind because we use snake_case on the wire, the Android worker never learns about it. It is writing its own version at that moment, and that version is also reasonable, just different.
Work like this needs proper agent teams, where everyone shares memory of what has already been decided. I switched to teams, and immediately got my first real incident.
Failure one: phantom agents
I ask for a team of three workers.
Team creation returns an error: Already leading team metro. It reads harmlessly, as though the state already exists and I can get on with it. I try to spawn a worker. It answers: Team metro does not exist. Call spawnTeam first.
The two messages contradict each other outright. One says the team already exists, the other says there is no team.
Then I did what almost any engineer would do. Deleted the team, created it again, retried the spawn. Error. Again. Error. Once more, and the spawns finally went through.
The team config file listed three members. The Claude Code interface showed seven or eight active parallel turns.
The phantoms had not gone anywhere after the “failed” attempts. They were alive in the process, simply absent from the config. And each was busy: loading the network schema into context, reading interchange-search files, working through map geometry. Eighty-seven, seventy, ninety-four thousand tokens apiece. In under ten minutes, roughly half a million tokens and half the session limit.
You cannot kill them selectively. They are not in the config, and a shutdown message addressed by name reaches one of the holders of that name non-deterministically rather than all of them. All that was left was to take everything down.
The rule that came out of this is unpleasant to an engineering ear:
Never retry an agent spawn after an error. Ever.
You stop, tell the human in plain text exactly what came back, and end the turn.
Experience with distributed systems teaches the opposite. We are used to retrying a network error with exponential backoff, and nine times out of ten that is right. Here the reason is specific: the operation is not idempotent and it completes partially. The error message refers to one part of the operation while another part has already happened and left a live process behind. Retrying is not trying again, it is creating another entity on top of the one you believe does not exist.
The conclusion is not limited to agents. Before wrapping a call in a retry, it is worth asking: if this returned an error, is it certain that nothing was created? For agent APIs today the answer is that it is not certain.
Failure two: two actors in one working tree
The team is working on onboarding and the paywall. The iOS worker is writing directly in the shared clone of the repository. In another tab I have my own session running an unrelated task, and it runs checkout in that same clone.
The iOS worker’s working tree reverts. The Xcode project file is overwritten. Files the worker had just created and registered in the project disappear from it.
Nobody gets an error. No exception, no red log, no failing test. The agent carries on convinced its files are where it left them, because it wrote them a moment ago. From git’s point of view everything is correct: one clone, one working tree, one HEAD, and whoever checked out last owns the tree. Two actors overwrite each other, and the one that got overwritten never finds out.
The Android worker was untouched that day, because it was the only actor in its clone.
The rule: any agent that writes to a repository lives in its own git worktree. A separate working tree, its own HEAD, and a checkout in the main clone cannot reach it. On iOS you add a separate derived data path, or parallel builds clobber each other’s artefacts.
A small but load-bearing detail: the same branch cannot be checked out in two working trees at once. The main clone releases it with git switch --detach first, and only then is the worktree created.
Simulators are their own story. If two sessions run tests at the same time, each needs its own, and it must be addressed strictly by UDID:
xcrun simctl create "onboarding-task" "iPhone 17 Pro" <runtime-id>
# in tests, always the id, never the device name
-destination 'platform=iOS Simulator,id=<UDID>'
Selecting a destination by device name is non-deterministic when several simulators share that name. Two sessions start fighting over one instance, tests fail every other run, and you spend half a day looking for a flaky bug in code that does not have one.
Both incidents share a denominator. The most dangerous failure in parallel work is the one that does not look like a failure. A retry that “finally worked”. A file that was “definitely written”. Neither raises an exception.
Failure three: parallel in, queued out
The third problem is systemic and does not yield to a one-line rule.
All four repositories build on one self-hosted runner. The runner is the same Mac the agents work on. You start four workers in parallel, they do a week of work in an hour, and all of it meets a single serial resource on the way out. Their local builds also take resources away from that runner while it is running.
A full iOS release rebuild takes 25 to 30 minutes. Uploading a finished artefact to the App Store takes three.

Three rules came out of that arithmetic.
First: the full CI equivalent runs locally before the push. A push must not be a probe for finding out what broke. It has to be green on the first attempt. The pattern of pushing, letting CI find the first problem, fixing it, pushing again and letting CI find the second costs several half-hour rounds on a resource the other three repositories are waiting for. For web the local gate is a build, the full test run, the coverage check and the visual tests. For iOS it is the unit test plan plus the affected UI tests on a dedicated simulator. For Android it is the unit tests of the affected module, the snapshot tests and a debug build.
Second: the first red check cancels the remaining runs on that pull request. The commit is not going to turn green. Everything still spinning on it wastes a resource another pull request is queued for. The temptation to let it finish and see all the errors at once costs more than it saves.
Third: when a late job fails, rerun only that job. If the build and tests are green and the store upload fell over, redispatching the whole workflow costs 25 to 30 minutes instead of three. GitHub reuses the results of the successful upstream jobs:
JID=$(gh run view <run-id> --json jobs
-q '.jobs[]|select(.name=="deploy_to_appstore")|.databaseId')
gh run rerun <run-id> --job "$JID"
A full redispatch is only needed when an early job failed or the sources changed.
And one domestic detail that cost me a release evening: a closed laptop lid means the runner went offline, and every in-progress job was cancelled. When your CI physically sits on your desk, leaving it to build and walking away does not work.
What became the system
Separation by file ownership. One repository, one agent. Not one agent per layer, not one per feature. Two agents editing one file will produce a conflict nobody notices.
Worktree isolation by default. Not when it seems necessary, but whenever the clone could conceivably be wanted by someone else. Isolation costs a few hundred milliseconds and some disk. Its absence costs work, with no trace left.
Enough context in the brief. An agent cannot see the lead session’s history. Everything you discussed an hour ago does not exist for it. A brief in the style of “do it the way we discussed” reliably produces garbage.
Three to five workers as the working ceiling. More is technically possible, but past that the volume a human has to physically check grows faster than the output.
Human gates between stages. After each stage I check it myself on a simulator and in a browser, and only then does the next one start. The agents wait.
Where agents do not help at all. Deciding whether a feature is worth it. Choosing what to do this sprint and what to never do. Working out why conversion dropped after a release. Drawing the tube map and deciding what a screen should look like. All of that stayed exactly where it was, and parallelism contributes nothing to it. Delegating those decisions produces coherent, confident, useless output.
What it actually costs
Tokens. Six roles, each with its own context window, each reading the same files as the others. That is expensive, and one phantom incident burned half a session in ten minutes.
Documentation discipline stopped being cosmetic. A stale comment gives a human a second of confusion. An agent treats it as truth: it goes looking for the named symbol, does not find it, and starts inventing. So we have a hard rule that any rename fixes the comments and documents referring to the old name in the same change, not later. That is protection against a whole class of errors, not tidiness for its own sake.
Platform idiom. I wrote about this above and will repeat it here, because it is the most underestimated line item. Consistency between platforms is bought by making each individual platform slightly less itself.
Review grew. This was the unexpected result of seven months. Code stopped being the bottleneck; verification became it. Four workers produce more diff in an hour than I can honestly read in a day. The isolation, the stage gates, the local CI run, the ceiling of five agents, the separate QA and analytics roles: all of it exists so that checking keeps pace with production, not so that code gets written faster.
Is this worth doing
Seven months and three incidents later I can answer that more precisely than I could in January.
The approach pays off when three conditions hold at once. First, you have several platforms that must behave identically, and that requirement is a product one rather than an aesthetic preference. Second, you can articulate clearly what the result should be, because here the brief is worth more than the code. Third, you are prepared to read someone else’s diff every day, because that is where the freed-up time goes.
It does not pay off in the inverse cases. A single platform with a strong character gains nothing: a canonical implementation, a port and a comparison pass are overhead that only makes sense with several clients. A task with an unclear outcome is also a poor fit, because if you do not know what should come out, four agents will faithfully make four different assumptions. And if review is already your bottleneck, parallelism will not widen it, it will block it completely.
If the conditions do hold, here is the minimum I would put in place on day one rather than learn the hard way:
- One repository, one agent. File ownership is divided before the start, not along the way.
- Worktree isolation immediately, for everyone who writes to a repository.
- No retries on a failed agent spawn.
- A full local CI run before every push, especially with a single runner.
- A human gate after every stage, not after the whole feature.
- An explicit list of what gets compared (element set, behaviour, data format) and what lives inside a tolerance (padding, micro-layout). Without that list the comparison either does not happen or eats the budget.
What I did not expect, and what turned out to matter most: the difficulty did not disappear, it changed address. It used to live in writing code and now lives in checking it. Every rule above exists so that checking can keep up with production. Speed is a side effect you only get if you solve the verification problem first.
Parallel agents do not turn one developer into four. They turn one developer into an editor, and the real question is whether you want to become one.
Tube Map, a London Underground app with an offline map and journey planning: https://redirect.appmetrica.yandex.com/serve/390252844080495467. This is the first article in a series about how it is built. Next: how we took map rendering from 2,959 draw calls per frame down to 19 across three graphics APIs, and how we took a 275 MB app apart down to under a hundred.