How I migrated 21,000 lines of production code with AI agents

2 days, 518 file changes, 0 regressions, 21,000+ lines - and why the right workflow mattered more than the model.

We did it with minimal hand-written tests, and still made sure every feature worked exactly like it did before the migration.

Here is how.

Migrations are scary

Migrations are one of the hardest things you do as a software engineer.

Before you touch anything, you have to be sure about the logic that is already there.

Why it was built the way it was.

What the feature was actually supposed to do. And every other part of the app that this feature quietly touches.

The main risks are always the same:

  • the scope of the change
  • keeping feature parity
  • how long it takes to stabilize
  • and what all of this does to the user experience

If you go in without enough context, the chance of shipping something that breaks production is very high.

So these migrations usually just sit there.


The mess I was dealing with

I ran into this while building a new feature. It touched one of the most fragile parts of our codebase: the core state layer for our avatar and voice data.

This part was so unstable during development that you could not really test against it.

Once in a while it threw race condition bugs and other issues that real users hit. It was a constant pain to maintain.

A few other things that made it worse:

  • It was old, hand-rolled state management for the avatar and voice data coming from the backend, with ~20 Redux actions scattered across the codebase. (can you believe that?)
  • Almost all of those files were @ts-nocheck, so there was no type safety to lean on.
  • It had around 20 useEffect hooks doing unnecessary work (syncing state that could have just been derived). They caused their own bugs, and we stripped them out during the migration.
  • It had no context about the data contract with the backend. If anything went out of sync, something broke.
  • Pagination, caching, infinite scroll, data flattening, and fetching were all built from scratch and spread all over the place.

Because of this, you could not change one part without risking a break somewhere else.


Why Redux was the wrong tool for this

Here is the core problem, and it is a common one.

Redux is great for client state.

Things your app owns and controls, like whether a modal is open, which tab is selected, or what the user typed into a field.

But the avatar and voice data was not client state. It was server state. It lived on our backend, and the frontend was just holding a copy of it.

Server state behaves differently.

  • It can go stale.
  • It needs caching
  • refetching
  • pagination
  • and invalidation when something changes on the backend.

If you manage it with plain Redux, you end up hand-writing all of that yourself.

And that is exactly what had happened here.

Our ~20 Redux actions were slowly reimplementing a data-fetching library by hand: caching, loading flags, infinite scroll, polling every 2.5 seconds, flattening responses, and deciding when to refetch. All of it custom, all of it spread around.

That is the exact job TanStack Query is built for. It handles server state out of the box: caching, deduping requests, background refetching, and invalidation.

Zustand then handles the small amount of real client state that was left, like which avatar is currently selected.

Two tools, each doing the job it is meant for, instead of one tool doing both jobs badly.


The tax we were paying

The reason this mattered so much was the blast radius.

This avatar and voice state was not used in one place.

It was read across almost every major surface of the app:

  • the video editor
  • the AI video genie
  • the manual video editor
  • custom avatars/photo avatars.

All of them pulled from the same global slice.

So one global list of avatars, with a single shared pointer for the next page, was feeding multiple screens at once. Change how one screen paginated and you could quietly break pagination on another.

There was no type safety to catch it, because these files were @ts-nocheck. And because everything was so tangled, you could not test one part in isolation.

The worst part was how unpredictable it got.

Because the caching was hand-rolled and done in the wrong places, the state became nondeterministic.

Sometimes the cache just would not update. We would see a bug, assume our own code was wrong, and go dig through it, only to find the code was fine.

The real problem was the cache and other moving parts interfering with each other.

You cannot debug with confidence when you cannot even trust that what you are looking at is the current state.

The result was a tax on every change. Any feature that touched avatars or voices took longer than it should have. You had to trace the logic by hand, worry about the other screens, and manually test everything to make sure nothing else broke.

Development slowed down as a result, and the risk of shipping a regression stayed high. That is the real reason this migration was worth doing.


The idea: what if I automated myself?

So one day I hit this wall again while working on the new feature.

My first instinct was to ignore it and patch around it. But then I had a different idea.

A few days earlier, Anthropic had released Fable 5. They were calling it a strong orchestrator, good at complex, multi-step work.

fable 5 model

I had also seen people on X doing great things with it. One user found a vulnerability in their own codebase that other models didn't even catch - one that even its creator had missed.

So I decided to actually test what everyone was saying.

I opened Claude Code, switched to Fable 5, and gave it a full brain dump:

  • what I wanted to do
  • the problem we had
  • and the issues it was causing.

Then I asked if it was even feasible to migrate this (Redux state) to Zustand and TanStack Query.\

It ran a planning agent (5 Opus 4.8 agents, each using 400k tokens in 4 minutes, crazy!) and came back with real research.

After reading it, I felt it was doable if I pushed myself to do it.

Quick note: this is not something I usually try to do, as there are other important priorities at hand.

My first plan was simple:

  1. write a plan
  2. let the agents implement it
  3. and manually test and give feedback on each step.

But that would be slow and tiring to babysit.

So I asked a better question:

What if I automated the steps I would normally do by hand to orchestrate the agents? What if I replaced myself with a main agent that held all the context, and kept that knowledge constant across sessions?

Here was the shape of it:

  1. The main agent holds all the context and tracks progress on every task.
  2. For each piece of work, it hands a sub-agent what it needs:
    1. the task
    2. the constraints
    3. and what the previous tasks did.
  3. The sub-agent makes the code changes and adds tests, then reports back.
  4. The main agent looks at the commits, opens the app in the browser using the Claude Chrome extension, and tests the change end to end.
  5. If it works, move on. If there is a regression, send the sub-agent back with the details and repeat until it is clean.

I talked this through with Fable, and since it already had deep context on the feature, it helped me sharpen the plan.

It also set up the whole scaffolding:

  • the plan
  • the orchestration layer
  • the task tracking
  • and the reusable context that every agent would run with.

One thing worth calling out.

Fable did the hard part: solving the plan and the architecture.

But Fable burns through weekly usage fast (as you might already know). So once the hard thinking was done, I switched the actual execution to Opus 4.8, both the orchestrator and the sub-agents. The hard problems were already solved in the plan. Running it just needed a capable model.


The workflow, at a glance

A main agent held all the context and orchestrated the work. For each work package, it handed the task to a sub-agent, the sub-agent wrote the code, and the main agent tested it end to end in the browser before moving on.

migration workflow (on a top level)

How we split the work

We split the migration into small work packages, WP-0 to WP-6, with one rule: only one running at a time, ordered from lowest risk to highest.

  • WP-0 was the foundation. Build the new data layer (the services, the query hooks, the caching keys), along with its tests and boundaries, before touching any screen. Nothing user-facing changed here.
  • Then, one screen at a time. We moved through the app starting with the least risky screen and working up.
  • The riskiest parts came last: the shared components everything depends on, the core generation flow, and finally deleting the old code. Each of these had a manual checkpoint where I reviewed the work before letting it continue.
  • The final work package removed the old state layer completely.

Each work package got its own fresh Claude session.

I noticed that orchestrating sub-agents and running the end to end tests pushed a single session past around 800k tokens. After that the model got slower and less sharp. One session per work package fixed that.

The thing that held it all together across these fresh sessions was simple:

The docs were the memory, not the chat.

Every agent read the plan and the brief for its context, and wrote its progress back to the task tracker. So even a brand new session knew exactly where things stood.


The invisible layer that broke things

Partway through, we hit a problem. There was still a hidden layer the agent could not see, so it was making assumptions.

Two things specifically:

  • No context about the backend.
    • The frontend was @ts-nocheck everywhere with no response schema. The agent did not know what payload was actually being sent in each API call, or what came back.
  • Tests only catch so much.
    • A test can confirm a function gets the data it needs in isolation. But real regressions only show up when you use the feature like an actual user. Not a smoke test. I mean running the full flow end to end, trying different combinations in the UI like a confused user would, and triggering the real API calls from the UI.

That kind of testing surfaced a lot of regressions the new code had introduced. In the early work packages I was reviewing the flow myself once in a while, and that is where I kept finding cases where the agent did not know what the payload looked like or how the data was sent.

The fix: show the agent what actually happens

So I built a small inject JS script that the agent installed in the browser devtools.

It captured every API call going to our backend:

  1. the payload
  2. the response
  3. the timing
  4. and a timestamp.

Then it fed all of that back to the agent.

This one change took us from introducing regressions around 50% of the time down to about 2 to 4% in the later work packages.

After that, the main agent could see the actual flow that was happening and apply the right fix. And every time we found a regression, we added a test case for it so it could not come back later.

That is how the whole migration went, end to end. I also caught a few smaller regressions myself along the way and got them fixed.

At the end, the PR was ready


What we got out of it

The migration paid off in the way that mattered most: the code became predictable again.

Server state is now handled by TanStack Query, so caching, refetching, and invalidation just work instead of being something we maintain by hand. And that fixed the deeper problems:

  • The nondeterministic cache bugs are gone.
  • The files have real types again instead of @ts-nocheck.
  • Each screen owns its own data instead of fighting over one global slice, so a change in one place no longer risks breaking another.

The practical result:

working on anything avatar or voice related is faster now, and far less scary. That fragile part of the codebase is not a roadblock anymore.


Where I took it next

Looking back, running a fresh session per work package was not the most efficient setup.

So after the migration, I took this whole doc-based workflow and turned it into an autonomous Claude Workflow.

Instead of me testing each step, there is now a dedicated review agent whose only job is to test like a rogue user, find issues, and hand them to a fix agent. That keeps the main agent's context intact and takes me out of the loop for most of the work.

I am still testing how well it holds up on the next big change. But the early direction looks promising to me.

The takeaway

With the right workflow and setup, you can pull off things that used to be a year long job before AI.

You just have to be a bit more ambitious.

We migrated 21,000+ lines of code in 2 days using coding agents and a workflow built to make it happen.

The workflow, not the model, is what made it safe.

Let me know what you think.

And if you have other workflows that have worked for you, I would love to hear them.

That's all for today!

Ayush