AI coding agents can work on legacy code, but not out of the box. An autonomous agent is only as good as the feedback loop the codebase gives it, and legacy systems give it almost none: sparse tests, conventions that live in people's heads, and dead code that looks load-bearing. Point an agent at a fifteen-year-old codebase unprepared and you get confident, plausible, wrong pull requests. Do three things first — characterization tests, tightly scoped tickets, and a watch-only period — and the same agent becomes useful on exactly the code nobody on your team wants to touch.
We run autonomous agents against customer repositories for a living, and the gap between how they perform on a healthy 2024 codebase and a 2009 monolith is the widest performance gap we see. This post is about why that gap exists and the preparation that closes most of it.
Why legacy code breaks agents that demo well
Every agent demo you've seen runs on a healthy repository: a modern framework, a test suite that means something, lint rules that encode the house style. That's not an accident. An autonomous agent works in a loop — read the code, make a change, run the checks, interpret the result — and a healthy repo answers back at every step. Legacy code doesn't answer back.
Michael Feathers defined legacy code in Working Effectively with Legacy Code (2004) as code without tests. Twenty years later, that definition turns out to describe agent-hostility better than any other. Three failure modes account for most of the damage.
No test signal. The agent's core feedback loop is change, run tests, read the result. On a module with no coverage, the suite goes green no matter what the agent does — green because nothing was measured. The agent can't distinguish a correct change from a plausible one, so it optimizes the only signal left: code that looks right. Looking right is what language models are best at, which is what makes this failure so quiet. The PR reads well. It compiles. Nobody finds out what it broke until production does.
Invisible conventions. In a ten-year-old codebase the real rules are rarely written down. All database access goes through the hand-rolled DAO from 2013; the ORM in the repo is only for the one service that got migrated; dates are UTC everywhere except billing, which is a known scar. Humans absorb this through review culture. An agent reads the code cold and writes fluent, modern, idiomatic — and wrong — code. The PR isn't broken, it's unmergeable, and unmergeable costs nearly as much review time as broken.
Dead code traps. Legacy systems accumulate parallel implementations: the
flagged-off experiment, the "old" pathway kept for a migration that finished
in 2019, three functions with nearly identical names in one util file. Agents
retrieve by search, and search doesn't know which of the three
calculateDiscount functions is live. We've watched an agent carefully
modify a code path, add tests for it, and open a tidy PR — against an
implementation no caller had reached in years. The tests passed. Nothing
happened.
Write characterization tests before anything else
The counterintuitive move: the first thing to point an agent at in a legacy codebase isn't the backlog. It's the missing test signal. Characterization tests — Feathers' term — pin current behavior rather than correct behavior. You feed the module inputs, record what it does today, and assert it keeps doing that. Bugs included. The point isn't correctness; it's a tripwire around behavior you can't afford to change by accident.
Characterization tests are also the rare legacy task agents are immediately good at, because they invert the trust problem. When an agent changes behavior, verifying the change requires understanding intent. When an agent writes a characterization test, verifying it requires only checking that the assertion matches observed behavior: run the test, watch it pass against today's code, done. It's grunt work with a mechanical review step, which is the ideal shape for delegation.
The sequence that works: pick the module you want the agent operating in, have it write characterization tests until the module's observable behavior is pinned, review those cheaply, merge them. Now the agent has a compass, and every later change it makes in that module gets real feedback instead of a vacuously green build.
Scope tickets like the reader has no context
The second lever is ticket scope. Agents fail on legacy code in proportion to how much judgment the ticket delegates, because judgment is exactly what an under-documented codebase can't support. The same agent produces wildly different results depending on what you hand it:
| The ticket says | What the agent does with it |
|---|---|
| "Refactor the billing module" | Invents a target architecture, touches 40 files, opens a PR nobody can review against anything |
| "Modernize error handling" | Fluent rewrites of code paths nothing pins, in a style the codebase doesn't use |
"Extract the tax calculation in Invoice.calculate into a pure function. Behavior is pinned by invoice_characterization.test.ts. Stay inside billing/." | One reviewable diff, a named test proving behavior held, a hard boundary on blast radius |
A good legacy ticket names the file, names the boundary, and names the test that proves behavior didn't move. If you can't write that ticket yet, the ticket you can write is "add characterization tests for X" — which is how the tested territory grows.
Run a watch-only period before anything merges
The third lever: don't let the agent's first weeks produce merges. Let them produce information. In watch-only mode the agent picks tickets, plans, and writes diffs, and everything stops at review. Two useful things happen. You learn the agent's failure modes on your codebase specifically — which conventions it misses, which dead ends it walks into. And every miss becomes a written rule: a line in the repo's agent instructions, a lint rule, a note in CONTRIBUTING. Tribal knowledge becomes written knowledge, which pays off for human onboarding too.
We ship watch-only as the default posture for exactly this reason, and legacy repos are where it earns its keep. One budget note belongs here as well: an agent with no feedback signal doesn't stop, it retries. On under-tested code that means longer runs and more dead ends, so a hard per-issue cost ceiling does more work on a legacy repo than it ever does on a greenfield one.
A realistic first month
Put the three levers together and the first month looks like this:
| Week | The agent's job | Your job |
|---|---|---|
| 1 | Watch-only: plans and proposes on small tickets, nothing merges | Review every proposal; write down each convention it misses |
| 2 | Characterization-test tickets only | Verify assertions match observed behavior; merge test-only PRs |
| 3 | Behavior-preserving refactors inside the tested perimeter | Normal code review, tightened by the pinned tests |
| 4 | Bug fixes and small features in covered areas | Expand the perimeter, one module per ticket |
The mental model that makes this sustainable is the perimeter. A legacy codebase isn't uniformly hostile; it's a small tested perimeter inside a large wilderness. The agent works inside the perimeter, where the feedback loop is real. Expanding the perimeter — more characterization tests, more written conventions — is itself work the agent can do. Wilderness tickets go to humans until the perimeter reaches them.
Where agents still lose on legacy code
Some legacy work stays human, and pretending otherwise burns trust. Whole codebase framework migrations have no perimeter to work inside — every file is in scope at once. Behavior archaeology loses too: the answer to "why does this discount exist" lives in a 2016 email thread, not in the code, and no amount of context retrieval finds it. Code that's only correct against production data shapes can't be pinned by tests the agent can run. And any change whose risk is political rather than technical needs a person who can be argued with.
None of that is an argument against using agents on old systems. It's an argument for pointing them at the 70 percent of legacy work that is mechanical, well-boundable, and miserable — and keeping the archaeology for people.
Feathers wrote that legacy code is code without tests. The agent-era corollary: an agent on untested code is autocomplete with a git account. Write the pins first — the agent is only ever as good as the feedback loop you hand it.