Coding agents get demonstrated on new projects, and there they look good: empty directory, clear brief, no history. The code that most companies actually care about looks different. It is twelve years old, it has survived four generations of developers, and half the decisions inside it are written down nowhere.
The common expectation at this point is that an agent copes worse with a system like that than with a new one. True, but not for the reason most people assume. It is not the language, not the age and not the style. It is that the decisive part is not in the code.
This article is about what an agent is actually good for in a legacy codebase, where it reliably fails, and which precondition makes the difference.
What an agent is missing in a legacy codebase
An agent reads what is there. In a grown system that is the smaller half of the truth.
It sees the branch that exists for one single large customer, but not that it has only been sitting there out of caution since that customer left in 2021. It sees two tables with almost identical content, but not which of them the billing system reads. It sees a function that looks dead, and not the nightly call from a script that lives in another directory.
This is not a model problem that goes away with the next version. That information exists nowhere as text. It exists in three heads, and one of them left last year.
From which follows the practical rule for everything below: in legacy code an agent is only as good as what somebody wrote down for it about the system, and that writing down is the actual work.
What it is reliably good for
Four tasks work better in a legacy codebase than scepticism suggests, and they share one trait: there is something that checks the result, and it is not the human.
Orientation. "Trace the path of an order from intake to invoice and name every file involved." That is a task a new developer needs two days for and an agent ten minutes, and the result can be checked against the code. It is also the task that shortens the entry into an unfamiliar system the most.
Characterization tests. Tests that record what the code does today, without judging whether that is right. The work is dull, voluminous and unpleasant for a human, and it is the precondition for any rebuild. For an agent it is ideal, because the result is immediately measurable: the test runs against the unchanged code, or it is wrong.
Mechanical rebuilds with a referee. Replacing a deprecated function in two hundred places, changing a signature, swapping an accessor. The referee is the compiler, the static analyser or the test suite. Where one exists, the work is done when it goes quiet.
Carrying a repeated pattern across. If eighteen controllers are built to the same scheme and each needs the same change, the first one is done by hand and the remaining seventeen are a task nobody will miss.
Where it reliably fails
The failure modes are just as predictable, and that is the good news: predictable things can be caught.
Silently changing behaviour. The most common damage does not come from code that does not run, it comes from code that runs and does something else. An agent tidies up an odd special case because it looks like an oversight. It was the implementation of an agreement with a customer.
Plausible assumptions about dead code. "This method is never called" is, in a legacy codebase, a statement about the search, not about reality. Calls assembled from strings, configuration files, cron jobs and other repositories are invisible.
Tests meant to pass. An agent that writes a change and its tests has two levers for the same goal. When it does not get through, it changes the test. That is not bad faith, it is the logical answer to a badly framed brief.
Chunks that are too large. A four thousand line file is no longer a problem for a context window. The relationship between that file and the eleven others depending on it still is. Large, vague briefs produce large, plausible, wrong results.
The precondition without which none of this holds
All of the above assumes the same thing: a way to check whether behaviour has changed. Without it an agent multiplies not the speed but the risk, at a pace the review cannot keep up with.
That does not mean a complete test suite has to exist first; that would be a project of months and the usual reason not to start at all. A net around the place being touched is enough, and that net is itself a good first task for the agent.
So the order that works is always the same, and it separates the two jobs deliberately: first the tests that record today's state, then a human looks at them, then the change. Ask for both in one brief and you get tests that fit the change.
Step 1 Write tests that record the current behaviour of
DiscountCalculator. Invent nothing and judge nothing.
Where you are unsure what is correct, record what the
code does and mark the spot.
(Human reads the tests. Not the code: the tests.)
Step 2 The tests are the boundary. Rebuild the discount tier
part without turning any of them red. If a test fails,
report it instead of adjusting it.The second sentence in step 2 is the most important one in the whole approach. Without it the boundary moves along with the work.
How that net comes about when there are no tests is covered in Testing legacy code when there are no tests.
The part that has to be written down
Which leaves the part we started with: the decisive information is not in the code. So it has to get there, into a place the agent reads on every task.
What belongs in it is not the architecture. It is what a new colleague would get wrong on their first day:
The table `orders_old` is still read but no longer written.
Billing depends on it. Do not touch.
Everything under src/Legacy/Export runs nightly from cron, not
through the application. Calls in there are assembled from
strings and cannot be found with a search.
Special cases carrying the comment "CU-####" are agreements with
individual customers. They look wrong and are not.
Ask before changing one.
Tests run with `make test`. Calling `vendor/bin/phpunit` directly
picks up the wrong config and is green when it should not be.This file is not written in one afternoon, it grows over weeks, and the trigger is the same every time: whenever an agent misunderstood something a human would have known, the sentence it was missing gets added.
The side effect is worth more than the trigger. What you have to write down for an agent is what you have to tell every new colleague anyway. After six months that file holds what used to live in three heads, and the bus factor is no longer one.
For mechanical changes a deterministic tool is the better choice: Rector in a legacy project.
What changes about review
Review does not become unnecessary, it shifts, and that is the part teams prepare for least.
With human code you mostly check whether the solution is right. With generated code the solution is usually plausible and often right; the two questions that matter are different ones. Is this the change that was asked for? And: what did it touch that nobody asked about?
From which follows a rule that helps more than any style guideline: a change request has one purpose, and everything in it has to belong to that purpose. A file tidied up along the way, a dependency changed along the way, a field renamed along the way goes back, even when each is an improvement on its own. In a legacy codebase the surprise costs more than the improvement.
The number to measure it by
Finally the question that always comes up with an adoption like this: how do you see whether it is worth anything?
Not in lines per day, and not in the share of generated code either. Both numbers reliably go up and say nothing about whether the system is easier to change afterwards.
The number that counts is the time to the first safe change in an area nobody on the team knows. It used to be days. With characterization tests, a maintained context file and a cut that changes one thing at a time, it is hours. That is the gain, and it does not come from the model, it comes from what was written down next to it.
How I introduce this in teams is described on its own page.
This article belongs to a series about systems that already exist. The retrospective orders every article in it by situation.

