Setting up a coding agent to work on a legacy codebase

Every legacy modernization conversation in the last year has included some version of the same question: can a coding agent just do this? The honest answer is that an agent is useful on a legacy codebase in proportion to how much context and how many guardrails you give it, and that setting those up is ordinary engineering work you do once per repository.

This tutorial is that setup. By the end you will have a repository an agent can work in without guessing: a written repo map, an AGENTS.md that states the rules of the codebase, a single command that proves nothing broke, and a short list of jobs we hand an agent versus jobs we do not. The examples assume a WebForms or VB6 repository with a SQL Server backend, but the shape holds for Classic ASP, Access and FoxPro.

Prerequisites: the legacy source in Git, a characterization-test harness you can run from one command, and whichever agent CLI your team uses. If you do not have the harness yet, build that first. An agent with no way to check its own work on a system nobody understands is the worst of both tools.

Step 1: give the agent a map before you give it a task

Agents are good at reading code and poor at guessing which of the four Customer classes is the live one. Legacy repositories are full of that ambiguity: abandoned folders, a v2 directory that was never finished, a DLL checked in next to the source that built it three years ago.

Write the map by hand, in one file, and keep it under a couple of hundred lines. Ours usually looks like this:

# Repo map

## What runs in production
- `Web/` — ASP.NET WebForms 4.8 app, deployed to IIS as /app. 214 .aspx pages.
- `BusinessLayer/` — VB.NET class library, referenced by Web.
- `Billing/` — VB6 project, compiled to Billing.dll, called from BusinessLayer
  via COM. Source of the invoice totals.
- `Jobs/` — SQL Agent job scripts and two VB6 EXEs.

## What does not run in production
- `Web2/` — abandoned 2017 MVC rewrite. Do not read, do not edit, do not copy
  patterns from.
- `Archive/`, `*.bak`, `*_old.aspx` — dead. Confirmed against IIS logs.

## Where the truth lives
- Invoice rounding: `Billing/modInvoice.bas`, function `CalcTotal`.
- Tax rules: stored procedure `dbo.usp_ApplyTax`, not in the app at all.
- Permissions: `aspnet_Membership` plus a home-grown `UserRights` table.

That last section is the one that earns its keep. On most of these systems a meaningful share of the business logic is in stored procedures, and an agent asked to "migrate the invoice screen" will confidently rebuild only the half it can see in C#.

The dead-code section matters nearly as much. We have watched an agent produce a clean, well-structured slice modeled on the abandoned rewrite, because the abandoned rewrite was the nicest code in the repository.

Step 2: write AGENTS.md as constraints, not encouragement

Most agent CLIs and coding assistants read an AGENTS.md (or an equivalent instructions file) from the repository root and prepend it to every session. Treat it as a place to write down the things a new senior hire would need to be told in their first week, and nothing else.

# AGENTS.md

## Build and test
- New code: `dotnet build Modern.sln` then `dotnet test Modern.sln`.
- Characterization harness: `./scripts/characterize.sh` (needs a restored
  copy of the test database; see scripts/README).
- The legacy `Web/` project builds only on the build VM with VS 2019.
  Do not attempt to build it here; do not "fix" it so it builds here.

## Rules for this repository
- Never edit files under `Web/` except the ones listed in
  `docs/shim-owned-files.md`. Routing changes go through the YARP shim config.
- Never edit `Approved/*.approved.txt`. Those files are the recorded behavior
  of the production system. If a test fails, report it; do not re-record it.
- Never change a stored procedure. Propose the change in your summary instead.
- SQL goes in `Data/` as parameterized commands. No string concatenation into
  SQL, including in tests and scripts.
- Target framework for new code is net8.0. Do not upgrade it.
- Money is `decimal`. Match the rounding in Billing/modInvoice.bas exactly,
  including the banker's-rounding behavior in CalcTotal.

## Definition of done for a slice
1. `dotnet test Modern.sln` green.
2. `./scripts/characterize.sh` green against the new slice.
3. Summary lists every behavior you could not reproduce, with file and line.

Two notes from experience. First, negative rules work better than positive ones; "never re-record the approved files" survives a long session in a way that "please be careful with tests" does not. Second, put the rule next to the reason. Agents and people both follow a constraint more reliably when they know what it protects.

Step 3: make one command the arbiter

The single highest-value thing you can do is make "did I break it?" a command the agent can run itself, unattended, in under a few minutes.

#!/usr/bin/env bash
# scripts/characterize.sh
set -euo pipefail

: "${CHARACTERIZATION_BASE_URL:?set to the system under test}"

sqlcmd -S "$TEST_SQL" -Q "RESTORE DATABASE app FROM DISK='/snapshots/app.bak' WITH REPLACE" >/dev/null
dotnet test tests/LegacyCharacterization -v q

Three properties matter more than coverage:

  • It resets its own state. A suite that leaves rows behind fails on the second run and teaches the agent that red is normal.
  • It is fast. If verification takes forty minutes, the agent will write a lot of code between checks, and you will get a large diff with an unknown number of regressions in it.
  • It fails loudly and specifically. "Expected NET30, got NET-30 in save-customer-1042" sends an agent to the right line. "1 test failed" sends it exploring.

Then say so explicitly in the task, not just in AGENTS.md: run the harness, and if it is red, stop and report rather than adjusting the test.

Step 4: hand over the jobs that suit it

After a couple of years of doing this on customer systems, our split is fairly stable.

Work we hand to an agent, with review:

  • Bulk characterization-test drafting. Point it at one legacy screen and the harness conventions, and it will produce thirty candidate recordings quickly. A senior engineer then deletes most of them and decides which remaining behaviors are contracts. This is the biggest genuine time saving we get, and the curation step is not optional.
  • Mechanical translation with a known target shape. VBScript string handling to C#, GridView markup to QuickGrid, ADODB.Recordset loops to a mapped list — once you have done the first one by hand and it is in the repo as the pattern to copy.
  • Inventory and archaeology. Every page that posts to a given handler, every caller of a stored procedure, every Server.CreateObject call and what it instantiates. Ask for a table with file and line numbers so the answer is checkable.
  • First-draft documentation of a module you are about to wrap in an API, to be corrected against the harness rather than trusted.

Work we do not hand to an agent:

  • Deciding the slice boundary. Sequencing depends on which data the business cannot afford to have wrong at 5pm on the last day of the month. That is not in the code.
  • Deciding which recorded behavior is a bug. Half the odd behaviors in a twenty-year-old system are load-bearing. Somebody's month-end close depends on the rounding you were about to tidy up.
  • Cutover and rollback. Human decision, human hand on the switch.
  • Anything touching the production database directly. Agents get read-only credentials against a restored snapshot, and nothing else.

Step 5: keep the diffs small enough to review

The failure mode is not the agent writing bad code. It is the agent writing two thousand plausible lines across nineteen files, all of which compile, with three subtle behavior changes in the middle. Nobody reviews that honestly.

What keeps it reviewable:

  • One module or one screen per task, on its own branch.
  • Require the harness to pass before you read a line of the diff. If it is red, the diff is not ready.
  • Read the parts where money, dates, permissions and rounding are computed, line by line, every time. Skim the plumbing.
  • Require the summary to list behaviors it could not reproduce. That list, not the code, is where the interesting problems are.

If a task keeps producing diffs too large to review, the task was too big. Split it.

What this does not fix

An agent does not remove the need to understand the system, it changes where that understanding gets applied: less time typing translations, more time deciding what is a contract and in what order the slices go. It will not read the intent of code that was written to satisfy a requirement nobody wrote down. It cannot tell you that the negative-quantity path exists because one customer returns pallets. And on the source stacks, its output quality drops noticeably the further you get from mainstream C#: VB6 forms and FoxPro report expressions get confident, wrong answers more often than WebForms code-behind does.

Which is the same reason the harness comes first. The agent is fast at producing candidate code; the recorded behavior of your production system is the only thing that can tell you whether the candidate is correct.