1 of 15Keeping the team shippingContents
  1. 1Keeping the team shipping
  2. 2The project at a glance
  3. 3Why the old app had to change
  4. 4What a module looks like
  5. 5Moving the code
  6. 6Merging in everything the team shipped
  7. 7Pointing LangWatch at our own agents
  8. 8Rules a machine checks
  9. 9Lint debt and circular dependencies
  10. 10Running the whole product locally
  11. 11Checking the new product against the old
  12. 12Faster checks for the whole team
  13. 13An upgrade system for cloud and self-hosted
  14. 14The final week
  15. 15What I learned

23 August to 12 October 2026

Refactoring at AI speed, without the AI slop

One engineer and a lot of AI agents rebuilt LangWatch in about seven weeks. The module structure, strict types and lint rules made a lot of slop impossible to write.

Act I

Why we rebuilt it

In the old app any code could reach any other, which made it hard to hand to agents, so we moved it into modules on a branch beside main.

  1. 01Keeping the team shipping
  2. 02The project at a glance
  3. 03Why the old app had to change
  4. 04What a module looks like
  5. 05Moving the code
  6. 06Merging in everything the team shipped

Keeping the team shipping

LangWatch is an LLM observability and testing platform. This refactor started as an experiment, and it kept proving more and more successful. We wanted to keep going and eventually get it merged, but we couldn't stop-the-world and code freeze until the massive refactor was done, we had to keep shipping. So we did the refactor on a long-lived branch and kept shipping fast on main.

Over the same 51 days the refactor branch took 9,187 commits and merged main in 30 times.

The refactor ran beside main for 51 days and took in main 30 times

main509 PRsthe refactor9,187 commits30 merges ↓agent testing v2voice agentsnew auth coreInstant Evalsautomations flowOTLP decoding23 Aug: branched off12 Oct: merged back, everything includedmain509 PRsthe refactor9,187 commitsagent testing v2voice agentsnew auth coreInstant Evalsautomations flowOTLP decoding23 Aug30 mergesof main12 OctMerged back, everything included

While the branch was open, the team kept shipping. At the beginning that was business as usual, at our normal rapid pace. But as the refactor got close to done, weighing the pay-off of shipping on main against the risk of rewriting it all on the new branch got trickier.

So we built a whole harness just for this. Claude, Codex and even Kimi worked side by side, frantically porting everything new on main to the new structure, while the massive verifications kept running all day and all night.

The project at a glance

The stages overlapped quite a bit. We spent the first two and a half weeks moving code, with few checks in place. On 11 September we read our own agents' traces and changed how we organised the work. Then came the rules, the lint and dependency work and the comparisons with main, and in the last few days a nearly three-day test drive and the upgrade trials. The release plan is still rough: cloud first, then self-hosted about two weeks later.

Eight overlapping phases: the big move, splitting the processes, isolated modules, lint to zero, matching main, decisions and migrations, the AI test drive and the upgrade trials.

Eight phases in 51 days

From the first commit on 23 August to the merge on 12 October

23 Aug15 Sep12 Oct
The big move23–30 Aug
Split the processes31 Aug–6 Sep
Into isolated modules7–14 Sep
Lint to zero15–24 Sep
Prove it matches main25 Sep–4 Oct
Decisions, migrations5–8 Oct
AI test drive9–11 Oct
Upgrade trials9–12 Oct
Merged12 Oct

Why the old app had to change

LangWatch had grown into a big monolith, with the Vite front end and its server in one package. There were so many different ways and places to put files that you never quite knew where something lived. Prisma was imported in ten different places, and any code could grab the app's shared services from anywhere. So when I changed the Slack reporting code for automations, it broke bug reporting, which is something I'd never have thought to check.

It was slow, too. The old app had 22.1 million type instantiations, and a type check usually took about a minute and twenty seconds, and up to two minutes on a bad run. I could tell when an agent was running one on my laptop, because the audio on my calls would just go to shit.

Agents make that mess worse. If you've worked in a codebase for years, you know which bits the rest of the app depends on. An agent starts every session without that, reads whatever code is nearby and copies it. LLMs also love going off on their own thing. You can give them as many ADRs as you want, and eventually they stop listening. Consistency has always made a codebase easier for people to work in, and it turns out the same goes for LLMs.

The plan was to turn every feature into a small module with its own data, its own tests and a contract (the only part other modules may use), with a machine checking the borders between modules. Then a person or an agent could change one module without reaching into the rest.

After: 66 modules, 53 core and 13 enterprise.

In the old app a change could reach almost anything; in modules, calls go one way and changes go out as events

Before: one appAny part can call any otherOne change reaches 23 of 28 partsso anything can break anythingAfter: modulesCalls go up the chain onlyevent streamprojectentitlementevaluatortracemonitorevaluationautomationspan receivedChanges go out as events; modules that care subscribeOne change: 3 subscribers react, nothing else is touched
A sketch. The swarm stands for the one old app (17,024 files). The modules are a slice of the 66; each arrow is a real declared call, and the subscribers are the real ones for trace's span received event.

It's basically microservices, just in the same process. The bet was that old-school software engineering, meaning tons of linting, could build a structure the agents aren't allowed to leave. Once the rules are strict enough, the AI gives up arguing and does what it's told, and it keeps going until the lint passes. That let one engineer directing agents do it in weeks while everyone else carried on shipping.

What a module looks like

We wanted every module to have the same layout, clear ownership and a small public contract, so an agent that has seen one knows its way around the next, and a reviewer knows where to look before opening a file. A module has up to four packages:

  • Contract: the only part other modules may use. It holds the module's API interface, its input and output schemas, its error codes and its route declarations.
  • Process: the server side. Services, data access, event handling, transports and the module's data migrations.
  • Browser: the screens and drawers the module shows in the UI.
  • Client: typed calls that other modules' screens use to reach this one.

Every module has a contract and a process package. Modules with screens add a browser package, and fewer still need a client package.

In the API framework, malformed input gets a 400 and input that fails validation a 422, both answered before the handler runs.

A module is at most four packages, and the apps only install them

The agent module, which has all four: what each package holds, and which app takes it

modules/agentone of 66, all built this waycontractwhat callers see: the interface,schemas, errors and routesprocessservices, its own tables,events and migrationsbrowserwith screensscreens, drawersand host mountsclientonly sometyped hooks for othermodules' screenseach app: main.ts and config.tsapps/apiserves the APIapps/workerruns the eventsapps/tasksruns the migrationsapps/uishows the screens
modules/agentone of 66, all built this waycontractwhat callers see: the interface,schemas, errors and routesprocessservices, its own tables,events and migrationsbrowserwith screensscreens, drawersand host mountsclientonly sometyped hooks for othermodules' screensappsapps/apiserves the APIapps/workerruns the eventsapps/tasksruns the migrationsapps/uishows the screens

The apps themselves (the API, the background worker, scheduled tasks and the UI) are now small entry points that pull in the modules they need from one generated list. Modules don't call each other's internals. They record what happened as events, and any module that cares subscribes.

The API framework does the same for routes. Each route is one declaration, with one line per check: which credential may call it, which permission it needs, the input schema, the audit record, a body size limit and the docs.

One declaration, seven layers of safety: without a grant, the handler never runs

agent-connect.rest.ts

.post("/connect/register", …)
  .withAudit("agents.register")
  .withInput(agentConnectRegisterInputSchema)
  .withCredential("project", { keyKinds })
  .withPermission(CONNECT_PERMISSION)
  .withBodyLimit({ maxBytes, onExceeded })
  .withDocs({ summary, description })
  .handle(async ({ app, input }) => …)
with a grantwithout onea request arrivesCredential .withCredentialwho is calling, before the body is readBody limit .withBodyLimitcapped before it is parsedInput .withInputvalidated against the schemaPermission .withPermissionthe caller's grant for this projectHandler .handleruns only nowAudit .withAuditthe call is recordedrefused, 403Before any requestTypes from the same schema, checked by the compilerDocs for OpenAPI, from .withDocs

That one declaration gets checked in two places. At compile time, the input and output types are inferred from the schema, and on tRPC you can't write the handler until the procedure says who may call it. Ask for a project permission on an input without a projectId and the compiler tells you so in plain English. At run time, the framework checks the caller's credential before it reads the body, then validates the input, then checks their grant on the new permissions system, and only then runs the handler.

Permissions are where an agent copying by hand is most likely to get something subtly wrong, so they live in the type system and the framework. Get one wrong and it either won't compile or gets refused before the handler runs. Before this PR, some of these checks were written out by hand in each route, and could be missed. Now a lint rule refuses any route that skips the declaration, and a route that writes without an audit record has to explain why in the code.

Moving the code

It took agents 11 days to empty the old app. They split it into feature packages (a temporary layout), several features at once, and by 3 September nothing was left in it. On 9 September those packages became isolated modules, so everything we wrote after that went straight into a module.

The old app went from 8,499 files on 23 August to none on 3 September; by the merge the modules held 18,823 files.

The old app emptied into feature packages, then everything became modules

Files in each layout at the end of every day

05k10k15k20k23 Aug9 Sep23 Sep12 Octold app empty54 modulesold appfeature packagesmodules18,823files010k20k23 Aug9 Sep12 Octold app empty54 modulesold appfeaturepackagesmodules18,823files

Only about one moved file in eight came through untouched, because moving a file nearly always meant changing it too. Imports change, a dependency has to come in through a contract instead of a global, and a component loses the context it used to reach out and grab.

Of the 37,718 files after the move, 22,351 are new

Every file in the repo before the PR and after it

Before24,252 filesAfter37,718 files9,788same path5,579moved8,885deleted9,7885,579of which 723 untouched22,351new filesBefore24,252 filesAfter37,718 files9,788same path5,579moved8,885deleted9,7885,579723 untouched22,351new files

So this was a rewrite as much as a move. Just over half the TypeScript lines we measured in the new code don't match any line in the old tree (we left out imports and SDKs, and a renamed line counts as new).

We had the fewest checks at this stage, so moves kept dropping UI inputs and callers, which we then had to find and put back by hand, and for a few days the branch wouldn't boot. Give agents speed and nothing to check their work against, and you get code that looks right but isn't wired up, and all the while main kept moving under us.

Merging in everything the team shipped

We had to carry every fix and feature that landed on main into a tree where a lot of files had moved or been split. Between 27 August and 9 October we merged main into the branch 30 times. Two of those were very large, with 1,282 conflicted files on 12 September and 455 on 5 October.

The 30 merges fell on 16 different days, and Git's saved resolutions replayed 2,755 earlier conflict fixes.

Main was merged in 30 times, and every change had to find where its code had moved

23 Aug12 Sep5 Oct12 Oct1,282conflicted files455conflicted filesEach change from main, carried acrossMain changes a fileApply it where the code lives nowTick it off the owed-ports listwhat changed, not the filerecord it23 Aug12 Sep12 Oct1,282conflicted files455Each change from main, carried acrossMain changes a fileApply it where the code lives nowTick it off the owed-ports listwhat changed, not the filerecord it

The worst conflicts were the ones left after Git said we were done. Resolving a conflict by keeping the branch's deletion could also throw away a change main had made to that file. A clean merge could put new code back into a directory we'd retired, where nothing called it. Either way, a teammate could finish and ship a feature, and it could be missing from the working product without a single conflict marker left to find.

We built a merge-drive skill to make that work repeatable. It starts from the actual merge state, then asks what main changed since the common ancestor. For a file we'd moved or split, the job was to carry that change to wherever the code now lived and check the behaviour there. It also covers the cases Git considers resolved: deleted files whose changes still need porting, old directories brought back by a clean merge, and features that only landed halfway.

From 21 September we kept a written list of every change from main not yet carried across, so a missing feature became a known defect with a name against it. With the skill, the next agent could pick up the merge without starting the investigation again. Git's saved resolutions dealt with the conflicts that came back merge after merge; the agents still had to check that main's behaviour survived.

Act II

Keeping it clean

The agents were getting expensive, so we read their traces. Then we wrote rules a machine checks, and the first full run found hundreds of circular-dependency problems on top of the lint debt.

  1. 07Pointing LangWatch at our own agents
  2. 08Rules a machine checks
  3. 09Lint debt and circular dependencies

Pointing LangWatch at our own agents

After the move we wanted two things: a proper look at how the agents were working, and rules a machine could check on every change. The look came first, on 11 September, with the cost climbing fast. (We ran the work on subscriptions, so every cost in this post is what the tokens are worth at API list prices, not what we paid.)

We traced every Claude Code session on this PR into LangWatch, so at 09:07 that morning the engineer asked Langy, LangWatch's own agent, where we could save the most. Langy read the PR's traces and came back with a ranked list. By then the PR had used about $28.7k across 110 sessions, and for every token the agents wrote, they re-read 493, so we were paying above all for the same context being read again and again.

Langy's six findings: every session started 70k tokens deep; in these long sessions cost grew with the square of the turns; tool output was re-read 26 times; a polling coordinator ran for 28 hours and cost $357; the priciest model did everything; and one 14-day session was 42% of the spend.

Langy found six ways to spend less

In LangWatch, 11 Sep at 09:07

Langy

i spent quite some money on this PR, where could I be making the biggest savings?

Read the PR's traces110 sessions
Added up the token usage43.7B tokens
Ranked where it goes6 findings

Most of the spend is the same context being read again and again. The six biggest places:

Where the tokens go
  1. 1Every session starts 70k tokens deep70k
  2. 2Cost grows with the square of the turns350–410k by turn 280
  3. 3Tool output is re-read26.3×
  4. 4A polling coordinator ran for 28 hours$357
  5. 5The priciest models do every job
  6. 6One 14-day session42% of the spend
ContextChoose what Langy can see

Ask Langy or describe what you want…

# Context/ Skills
The rules landed at 12:53 the same day (442b11c726)

Less than four hours later we had turned the findings into rules that cut how much each agent re-reads. We shrank the instructions every agent session loads (CLAUDE.md) and moved the detail into files that only load for the code an agent touches. Agents hand over to a fresh agent after a set number of tool calls instead of piling up history, and batch their work into fewer, fuller turns. The coordinating agent stopped polling and reacts when work is reported back. We put Opus on the jobs that need judgement and Sonnet and Haiku on the mechanical ones, and swapped long sessions for short assignments, each with a written brief and its own set of files, with no more than six running at once at first.

Some of those rules helped quality too. A short assignment with a written brief is less likely to wander off into another module, and a fresh agent picking up a handover starts from the brief instead of hundreds of thousands of tokens of old history.

Our detailed logs only start on 13 September, two days after the change, and have a gap from 4 to 7 October. An agent-hour in them means active agent time, added up across agents and subagents. We first used Opus 5.5 on the evening of 22 September, which splits the log into three stretches:

An agent-hour got 58% cheaper while we ran 2.3 times as many

Dots are single days; the orange line is each period's average

Before Opus 5.5With Opus 5.5Final pushno logsCost per agent-hour, at list price$10$20$30$40Agent-hours a day100200$25.1051 a day$13.7073 a day$10.60116 a day13 Sep20 Sep27 Sep4 Oct12 Oct
Before Opus 5.5With Opus 5.5Final pushno logsCost per agent-hour, at list price$10$20$30$40Agent-hours a day100200$25.1051 a day$13.7073 a day$10.60116 a day13 Sep20 Sep27 Sep4 Oct12 Oct
Claude Code logs on the engineer's laptop, every subagent counted. Logs start 13 Sep, two days after the research; there are none for 4 to 7 Oct.

Total spend kept going up, but each dollar bought more recorded agent time. The workflow, the models and the tasks all changed at once, so I can't tell you which change saved what. The whole PR came to about $96k at list price across 555 sessions. The way we coordinated the agents grew out of that morning.

Rules a machine checks

Put ten agents on a codebase at once and you get ten ways of writing the same thing. None of them remember yesterday's conversation, so anything that only lives in someone's head or a review comment gets reinvented the next morning. The rules had to become checks a machine runs, and every agent had to read the same instructions.

By the merge we had 76 custom lint rules, all errors, so there was no pile of warnings to scroll past. They cover file names and locations, imports, route permissions and a five-line limit on comments. Longer explanations belong in an architecture decision record (ADR), with a short link from the code. That keeps long, confident paragraphs out of it. Agents do love writing those.

The architecture enforcer checks what a linter can't see from one file. Its policies look across the whole tree: a module only sees another module's contract, each data-access repository belongs to exactly one module, and no module may end up calling itself through a chain of others. If an agent broke the layout, the build failed.

The errors were prompts too

A rule that only says 'wrong' leaves the agent to invent the fix. Our rule definition gives each lint message three parts: what is wrong, why it matters and what to do instead. The reason has to fit on one line. Here are examples from the rules in the repository:

  • Condition shape: if a test makes too many calls, the error counts them and gives the limit. Its reason is that a test you can't read at a glance is where the wrong branch hides. The fix tells the agent to read calls into named constants or split the test into early returns.
  • Comment size: a long explanation belongs in an ADR, rather than a block of commentary in the implementation. The error tells the agent where to move it. Shortening the paragraph until it fits isn't the point.
  • Application boundaries: if one app imports another app's source, the enforcer names both roles and tells the agent to move the reusable behaviour to its owning feature or infrastructure package. It gives the agent somewhere to put the code, rather than just forbidding the import.

We wrote a lint-rule skill for adding these checks too. It asks for examples that should pass and fail before the implementation, and a fix an agent can apply without guessing. It also sends each check to the right place, so a local syntax check goes in lint, a rule about relationships across packages goes in the architecture enforcer, and taste with no concrete fix stays in the docs instead of becoming another error.

Above both sits the architecture record, one document of over 3,000 lines that says how the code is laid out and why. If the record and the code disagree, the code is wrong. The record also lists the ways of writing things we've retired, so an old pattern can't sneak back in just because an agent found an example of it somewhere.

Human decisions go in the record too, so an agent never has to ask a question that's already been answered. When an agent hits a question the record doesn't answer, it stops and asks, and the answer goes into the record as a numbered decision for later agents to reuse. Feature specs do the same job for behaviour, and if no scenario covers a change, an agent writes one first, error paths included. Skills (the written instructions an agent loads for a kind of task) grew from a handful to over 60.

How we coordinated the work

Every question the agents asked came back to one person, the engineer, around 70 a day through the month from 13 September. They were questions like which module owns this data, whether something should be an event, or whether a difference from main is a bug or an improvement. The agents researched each question first, so a decision usually arrived with the options laid out, what each would cost and which parts of the code it touched.

We put the working instructions in the coordinator skill, the lane agent and the orchestration protocol. The coordinator reads the written work records and gives each lane a bounded assignment: the files it owns, the model it runs on and what done looks like. Lanes work on separate files; shared changes go back through the coordinator. When an agent hits its tool-call limit it writes a handover and stops, and a fresh agent carries on from that record. Questions go to the engineer and the answers go into the architecture record. The work itself lands in small commits.

Agent-hours peaked at about 200 a day on 30 September and on 9 and 10 October, with up to 43 agents running at once.

One person decides; agents build, test and report back

The working loop from 11 Sep on, run many times a day

decisionsquestionsbriefscodebuildsfindingsfix listThe engineerAnswers questions,makes every decisionCoordinator agentWrites the briefs,commits the workCoding agentsOpus and Sonnet,at most six at onceThe PR branchRuns on a local stackwith simulatorsTestersHaiku walks everyscreen of the productLedgers on GitHubEvery tested flow andopen finding, with proof
decisionsquestionsbriefscodebuildsfindingsfix listThe engineerAnswers questions,makes every decisionCoordinator agentWrites the briefs,commits the workCoding agentsOpus and Sonnet,at most six at onceThe PR branchRuns on a local stackwith simulatorsTestersHaiku walks everyscreen of the productLedgers on GitHubEvery tested flow andopen finding, with proof

In mid-September every agent ran its own checks as well as writing code. By 30 September there were 33 agents running at once, which was too much for the laptop, so from then on agents only wrote code and the heavy checks waited in one shared queue. It was also the busiest day for typed messages, with 782 of them.

Up to 43 agents ran at the same moment

The most agents running at once on each day, 13 Sep to 12 Oct

10203040no logs43 at once13 Sep20 Sep27 Sep4 Oct12 Oct
10203040no logs43 at once13 Sep20 Sep27 Sep4 Oct12 Oct
Claude Code logs on one laptop, every subagent counted. Logs start 13 Sep; there are none for 4 to 7 Oct.

By the final days, cheap Haiku testers were finding problems and Opus was fixing them, with Codex doing research and second-opinion reviews on the side, all on the same laptop.

Lint debt and circular dependencies

With the rules in place we went module by module. Once a module passed the lint and architecture checks, the same checks stopped it slipping back. On 16 September we deleted the list of lint exemptions to see how big the job really was.

Circular-dependency findings as measured: 267 on 2 October, 248 on 6 October, 159, 145 and 135 on 8 October, 73 early on 10 October, then zero, with organisation and identity kept linked on purpose.

More than half the lint debt was below the waterline

Lint findings on 16 Sep, with the iceberg's areas drawn to scale

the exemption list7,166visible while the exemption list existed+ 9,064more, hidden by it until itwas deleted on 16 Sep16,230real findings
7,166visible above theexemption list+ 9,064more, hidden by ituntil it was deletedon 16 Sep16,230real findings

We got the findings to zero one module at a time. Every design question they raised went into the record, so the next module didn't need the same conversation. A few things broke along the way:

  • 14 September: the sign-in loop. The REST API accepted the session cookie while tRPC, the UI's other API, answered 401 to everything, so as far as the UI was concerned, no user was ever signed in.
  • 18 September: the UI wouldn't start at all, and the test meant to catch exactly that stayed green. It turned out the test had its own exemption list. We removed it.

Breaking the circular dependencies

The circular dependencies were the most painful part of the whole PR. That's a module calling another module that, a few modules later, calls it straight back. Modules stuck in a loop like that are hard to change, test or put in order on their own. The checks flagged 26 of them among the browser packages on 8 September, but when we counted the architecture policy's findings properly across the whole tree on 2 October, there were ten times as many, and some had never been listed anywhere. On 5 October we deleted the allowed list, so every circular dependency failed the check.

The fewer links we had to cut, the fewer design calls the engineer had to make. On 6 October one knot of 40 modules was left, with billing, licensing, gateway and entitlement (the module that handles usage) all calling each other in the middle. A greedy approach wanted to cut nearly twice as many links as the layering we had designed. By 8 October the knot had shrunk, and an exact solver found the smallest set of links to remove, 32 of them.

A knot of 40 modules, untied one link at a time by 10 Oct

Orange links are circular calls; organisation and identity stay linked as a named exception

gatewaybillinglicensingentitlementorganisationidentitykept on purpose
gatewaybillinglicensingentitlementorganisationidentitykept on purpose
circular dependency findings2 Oct6 Oct8 Oct10 Oct267 on 2 Oct248 on 6 Oct,one knot of 40 modules135 on 8 Oct, evening:22 links left to decide73 at 06:00 on 10 Oct,one link left: licensing calls gateway0at 06:28
A sketch of the knot, not the real graph. The counts are the measured circular dependency findings.

Each link we removed meant deciding who owns the data, or whether a call should be an event. The agents did the research on each one, so the engineer got the last links one at a time, with the options and what each would lead to, and we wrote every decision down.

Four kinds of fix came up again and again:

  • Listen for the event: react to the other module's event instead of calling it.
  • Move the operation: put it in the module that owns the data.
  • Keep a local view: push the data as an event into a view the caller builds from events.
  • Use config: replace the call with a config value.

The last link went at 06:28 on 10 October (licensing no longer calls gateway), and the policy findings dropped from 73 to zero. Removing one link can clear a lot of findings at once, because many circular chains run through it. One dependency stays on purpose, between organisation and identity. It's named in the rule as an accepted exception, so anyone who wants another one has to argue for it in the open.

Act III

Testing it and preparing the release

Then we had to show the new product still behaves like the old one. Along the way we started checking each module on its own, which made a UI typecheck about 30 times faster, and we built an upgrade every install can run on its own.

  1. 10Running the whole product locally
  2. 11Checking the new product against the old
  3. 12Faster checks for the whole team
  4. 13An upgrade system for cloud and self-hosted
  5. 14The final week
  6. 15What I learned

Running the whole product locally

The test drive later on meant running the whole product many times over on one laptop. You can't do that if every agent needs a Stripe account, a SAML provider and a credit card, or if forty of them are sharing one database.

A stack for every working copy

haven, our local development tool, starts a separate copy of LangWatch for each working copy of the code: UI, API, worker, databases and stand-ins for outside services. Agents can work side by side without treading on each other, or on the person at the next desk.

  • No secrets in sight: agents read the environment through haven, which masks the secrets in it.
  • Seeded on start: haven fills an empty stack with users, organisations and logins.
  • A feedback button on every page: leave a note, and the agent gets the selector, viewport, console and network calls with it.
  • A shared queue for heavy checks, so 40-odd agents don't overload the laptop.

haven itself got a lot lighter, which matters with 40-odd agents on one laptop. Idle CPU fell to about a tenth of what it was, and the shared browser needs about a third of the memory it did.

Stand-ins for outside services

Every outside service LangWatch talks to now has a local stand-in, twelve in all. Tests can make one fail on purpose (a 429 from the LLM, a declined card, a batch of a thousand user-provisioning updates), and we control the responses, which cost nothing. That matters for quality as much as cost, because an agent can actually prove an error path works.

The twelve stand-ins: LLM providers, payments, sign-in (OIDC, SAML and SCIM), passkeys and one-time codes, GitHub, S3 storage, email, Slack and webhooks, analytics, Lambda, voice and telemetry.

Twelve outside services, each with a local stand-in

So the whole product, and every test of it, runs on one laptop

LLM providersevery model providertold to fail: 429PaymentsStripe, with a test clockSingle sign-onOIDC, SAML, SCIMTwo-factor sign-inpasskeys, TOTPGitHubthe GitHub AppFile storageS3 and uploadsEmailevery email sentMessages outSlack, webhooks, SQSProduct analyticsPostHog, Customer.ioServerless functionsAWS LambdaVoice callsElevenLabsTelemetry intraces, logs, loadLangWatchon one laptopLLM providersevery model providertold to fail: 429Emailevery email sentPaymentsStripe, with a test clockMessages outSlack, webhooks, SQSSingle sign-onOIDC, SAML, SCIMProduct analyticsPostHog, Customer.ioTwo-factor sign-inpasskeys, TOTPServerless functionsAWS LambdaGitHubthe GitHub AppVoice callsElevenLabsFile storageS3 and uploadsTelemetry intraces, logs, loadLangWatchon one laptop

One shared headless browser

Agents test the UI through one shared headless Chromium (Playwright's headless shell, GPU off). It serves every agent on the machine, up to 24 at once, each in its own isolated browser session that's already signed in, so an agent never sees a password. For flow testing it reads the page's accessibility tree, which tells it straight away where the Save button is. Screenshots are kept for proof and for visual checks.

We tuned it so two dozen agents can share it on one laptop and still leave room for everything else. Images, fonts, media and animations are off, idle pages and sessions close after a few minutes, and the browser can never take more than an eighth of the machine's RAM.

It can also record any walk through the product as a script, even after it ran (it keeps each session's last 200 actions), and replay it strictly, stopping at the first step that differs. Scripts export to Playwright tests too. We aren't running it yet, but that makes a nightly replay possible. The idea is that an agent walks a flow such as onboarding once, the script replays every night with no AI involved, and an agent only gets involved when it breaks.

Checking the new product against the old

None of this is worth much if the product stops doing what it did before. So we compared the old and new versions, then ran a test drive across the whole product, with agents doing the clicking and the engineer deciding on every difference that mattered.

First we ran two comparison tools, apidiff and visualdiff, which call the API and open the screens on both versions and compare the results. They raised hundreds of findings, and for over a hundred real differences we had to decide which version was right. Our default was to keep main's behaviour and the branch's code structure. An endpoint that used to crash with a 500 and now answers with a clear 400 counts as an improvement. A field that went missing is a defect, however tidy the new code around it looks.

Then came the test drive, nearly three days of agents clicking through the whole product, from 9 to 11 October. Haiku tester agents drove the UI, Opus agents fixed what they found, and the fixes were committed as they landed. Between them the testers worked through more than a thousand flows (paths through the product, such as onboarding), and each result went in as a row on a GitHub tracking issue. Each tester claimed a row before starting, so two of them didn't end up on the same check at once.

989 of the 1,016 rows passed, about 97%.

136 differences decided by a person, then 989 of 1,016 ledger rows passed

Two checks of the new product against the old one, in the order they ran

Old and new compared, every endpoint and every screen

454 differences found between old and new

200 checked and confirmed as real

136 decided by a person: which version is right

The 65-hour test drive, 9 to 11 Oct

Each square is one row on the GitHub tracking issues where the testers recorded their results

989 passed27 not recorded as passed

27 rows weren't recorded as passed, and they're on the list to retest before the release. Partway through, the first tracking issue hit GitHub's limit of 2,500 comments, so we carried on in a second one. Haiku is cheap enough for this sort of work. In the last weeks it ran 37,734 turns for about $231 at list price, both as the testers and as the judge of screenshots.

Fuzzing in CI adds one more check by throwing malformed data at the product and logging anything it handles badly. Across the PR, the number of test files grew by about half.

Faster checks for the whole team

Splitting into modules also made checking the code a lot cheaper, and that helps the team building LangWatch every day, the same people who kept shipping through all of this. Before, every type check loaded the whole app as one program. Now each of the 411 TypeScript build projects is checked on its own against its neighbours' already-built types, so a change only rechecks what it touches.

A UI typecheck now takes well under a second, about 30 times faster than midway through the branch, because the compiler does over a hundred times fewer type instantiations (the work of expanding and checking types). Saving a file in development now rebuilds in milliseconds instead of a second or more. A lot of that comes from much smaller generated type files, the ones a package publishes for the packages that use it.

Generated type files: the annotation API went from 911 KB to 3.4 KB, the trace read hook from 930 KB to under 2 KB, and the agent tRPC types from 370 KB to 5 KB.

Smaller shared type files, so checking the UI takes 127× less work

A type instantiation is one unit of the compiler's work. Grey is before, orange is after.

Generated type files every other package reads, three of them

Trace read hook930 KBunder 2 KB
Annotation API911 KB3.4 KB
Agent tRPC370 KB5 KB

Type instantiations to check the UI

22.1Mbefore174k after

127× less work

Other checks, before and after

UI typecheck from cold294 s18 s
One package, warm8.4 s0.5 s
Memory, full typecheck5.9 GB3.1 GB

There's a catch. Checking all 411 projects from cold is about six times the compiler work of the old single app, though you rarely need to. We measured these numbers at individual changes, some of them on a busy machine, so trust the instantiation counts more than the seconds.

Installs got lighter too. Installing the SDK no longer downloads a 38 MB cloudflared binary, and importing the Go SDK brings in 248 packages instead of 389, with no gRPC packages (down from 105).

Testing only what a change affects

CI follows the same borders. It used to run everything on every push. Now Nx works out which projects a change can reach, typecheck keeps every package's built types and only redoes the ones whose source changed, and only the apps a change reaches (API, worker or UI) get tested. If that step fails, all three get tested. Lint only runs on affected projects too, while integration and repository tests are never cached. The rules the agents worked under now run for everyone on the team.

We can't say yet whether CI got faster, because the first app CI runs on main after the merge all failed until a fix landed on 13 October. Before the merge, the median app CI run on main took about 13 minutes, and we'll compare once main is green.

An upgrade system for cloud and self-hosted

All of this has to reach installations that are already running, without losing their data, so we built a new upgrade system for cloud and self-hosted alike. Data migrations run in the background while the product keeps serving. Every step can be interrupted and picks up where it left off, workers hand over to the new version gracefully, and an Upgrades screen shows how far along it is.

Self-hosted installs are the hard part. They upgrade on their own machines, often without anyone from LangWatch anywhere near. Every migration rule we have (copy, never move; every step resumable; the previous release keeps working on the new schema) is there so an upgrade can finish without anyone around to rescue it.

The production image, compressed as pulled: 1,528 MB at its largest in 1.7.0, 494 MB in 3.20.1, about 253 MB after the merge.

Background migrations resume while the old version serves

One image and one upgrade path, for cloud and every self-hosted install

new image deployshandover
Old versionkeeps serving
Background migrationspick up where they left offinterrupted
New versionnew workers take over

Upgrade trials2,007 organisations200,000 usersNo telemetry lost

upgradelab, our upgrade test bench, ran real upgrades from the old version on built code, under load, with crash drills. Setting up a trial's data now takes about 1,600 requests instead of over 100,000, which meant we could run trials over and over. They caught jobs lost when a worker crashed, queue floods, and stale reads on replicated ClickHouse. Working through the migration plan, we also found two outages that would have hit the very first deploy, and fixed both before anything shipped.

The trials upgraded about 2,000 organisations and 200,000 users without losing any telemetry. Fixes made along the way shrank the peak job queue at scale about fifteen times and cut the memory held by ClickHouse reads in flight to about a quarter. We measured all of this in trials, before any production release.

Cloud and self-hosted now run the same image, built from the public repository, and it's about half the size of the last release. A follow-up change after the merge (PR #8662) measured it on 13 October. The main reason is that we now build one image with only production dependencies, leaving out the development and UI-build packages. We haven't tried slimming it any further yet.

The new image is about half the size of the last release

Production app image, compressed as pulled; the new image was measured after the merge, in PR #8662

1,426
1,528
351
494
591
~253
1.0.0Oct 2025
1.7.0largest
3.10.0
3.20.1last release
mainbefore the merge
New imageafter the merge

MB, compressed

Release images as published; the new image from the image-compat work in PR #8662, before any deliberate slimming.

The final week

We made 3,228 commits from 5 to 11 October, about a third of the whole PR in its busiest week by a distance, with 750 of them on 9 October alone. The engineer was answering questions from the test drive, from the last circular dependencies and from merging main, all at the same time.

Across the whole PR, we landed about a quarter of all the commits between midnight and 6am Amsterdam time.

Across all 51 days, 2,253 of the 9,187 commits, about a quarter, landed between midnight and 6am Amsterdam time.

A quarter of all commits landed between midnight and 6am

Commits per day over the 51 days, Amsterdam time

between midnight and 6am: 2,253 of 9,187 (25%)rest of the day

200400600800
23 Aug1 Sep15 Sep1 Oct12 Oct

Some of that was agents running investigations while everyone slept, which suits them fine. Some of it was the engineer steering them. On about 40 nights over the 51 days there were commits or typed messages between 1am and 6am (we don't recommend it).

What I learned

We squash-merged PR #7536 on 12 October 2026. It carried everything the team had landed on main along the way, across almost 46,000 files, so thanks to Rogério Chaves, Sergio Cardenas, Andrew Joia and everyone else who kept shipping while the branch changed underneath them.

What production caught

The refactor has now rolled out. We missed a field in the agent editing UI, and we hadn't connected NLP back up. Both reached production despite all the checks described above.

Merged on 12 October, rolled out to cloud; self-hosted soon

  1. DoneMerged12 Oct, 08:12 UTCevery PR from main included
  2. DoneCloud releaseNow in productionproduction bugs discussed below
  3. NextSelf-hosted releaseSoonsame image, same upgrade path

What I'd do again, earlier

  • Invest in simulators early. We ended up with a simulator for everything external (LLMs, voice, S3, SQS, email, Slack, GitHub), so agents could exercise every behaviour end to end instead of just opening a page. That's the kind of check that catches a field that doesn't save.
  • Think hard about a strict structure and agree on it with the team before the mass editing starts, with the machine-checked rules, the decision record and the review of our own agent traces already in place. We added them after the move and spent weeks finding out what it had broken.
  • Be more ambitious with lints than feels reasonable. If something isn't consistent, add a rule so it has to be. That's what kept 46,000 files consistent, and it's the part I'd copy first on any codebase handed to agents.
  • Treat every merge from main with suspicion from day one. Changes vanished in merges that came out clean, so a clean merge doesn't prove a change arrived.
  • Follow a real request end to end. A green check elsewhere didn't tell us NLP was connected.
  • Keep inspecting your own agent usage. On a project this size, going through my own Claude Code sessions for frustrations, cache resets and rabbit holes is what made the token spend worth it. You can track your Claude Code usage the same way.
  • Polish your own harness as you go. Every new way of checking the agents' work bought more trust in what they produced.
  • Be more ambitious with Jev, TypeSafe's judge model. Halfway through, we talked about using it as a linter. I tried it a little, but the context window wasn't big enough to be useful on my tests, so I dropped it. Next time I'd try it earlier and push harder, because it's one more tool for trust and against slop, next to the lints, the simulators and the trace reviews. See how Jev compares to the alternatives.