How one person runs a 1.4M‑download product with AI agents

Summary

I run VINDecoded, a profitable vehicle-research product on Android, iOS and the web, as a one-person engineering team where AI agents write most of the code. From June 13 to October 28, 2026, 1,235 pull requests merged into its monorepo, and 1,028 of them (83%) came from agent branches in Claude Code and Codex.

The agents are not the interesting part. The system around them is: docs every agent reads before it starts, one isolated checkout per agent, tests the coding agent can’t rewrite, a second agent that reviews its work, a gate and a kill switch on every new feature, and one audited door to production, with evals and alerts feeding back into what gets built next. A hook enforces the few rules that can’t bend, and scheduled agents work within written limits on what they may touch. The code is private, so this essay shows how that system works with real excerpts and the failures that shaped it.

MeasureCountHow I counted
Pull requests merged1,235Monorepo, June 13 to October 28, 2026
From agent branches1,028 (83%)Branches named agent/, codex/, claude/ or monitor/
Test files1,530Every test file in the monorepo except a separate side-project game
Runbooks113docs/runbooks
Architecture decision records46docs/decisions
Feature gates126Gate names in the shared app-settings schema
Agent skills14Step-by-step procedures agents load on demand

Android has 1M+ downloads at 4.5 stars and iOS has 400K+ downloads at 4.7 stars.

The product and the constraint

VINDecoded turns a VIN or a license plate into what a person needs to know about a car: specs, recalls, market value, the window sticker and its history report. Owners also use it to track maintenance, fuel and recalls, and to read their car through an OBD-II adapter. Shoppers search used-car listings, and dealers have a plan and tools of their own.

It is one product on three platforms. A React web app runs in the browser and inside both mobile apps. Native Android (Kotlin) and iOS (SwiftUI) shells add what the web can’t do: Bluetooth to the car, store billing, ads and the camera. Behind them sit a Node API, SQL, Firebase and a server-rendered public site. It earns money from ads, one-time reports, credits and memberships.

The constraint is that I am the whole engineering team. Agents can produce code faster than I can read it, so typing was never the bottleneck: my attention is. Every pull request costs some of it, and a mistake that reaches production costs real customers money and trust.

So I built the system around two goals: get the most finished work out of each hour I spend, and keep any one mistake small.

The system at a glance

How a change moves from a request to customers and back. Agents do the work; I step in at five points.Agents do the work; I step in at five pointsI step inOnly for some changes1 · PlanRisk tier and a planof small slices, eachverifiable on its ownAcceptance criteriamedium/high risk onlyMocks of new screensUI changes onlyI approve the mocksand, for high risk,criteria and tests2 · Build, one slice at a timeIts own worktreeand port slotTests written first,then lockedmedium/high risk onlyCode and unit tests3 · Prove itAI tests the UIon web and devicesUI changes onlyScreenshots checkedbeside the mocksUI changes onlyA second agentreviews the diffmedium/high risk onlyEvals: the real modelon frozen casesAI changes only4 · Show me, then mergeScreenshots and avideo sent to meUI changes onlyI try it on my phoneover TailscaleoptionalPull requestwith its evidenceMerged when I say,then the next slice5 · Release and turn onI ask for a releasePinned master,checks for that diff,mostly on my desktopProduction push;GitHub deploys it,gate still offGate on: my account,then everyone;kill switch kept6 · Watch and learnHeartbeats and alertsemail me, even withmy desktop offScheduled agentstriage errors, cost,store rank and adsEach lesson becomesa test, rule, alertor eval caseFindings go back to1 · Plan as thenext fix or feature
How a change moves from a request to customers and back · 6 phases, 5 points where I step in

Merging, releasing and turning a feature on each wait for me. A UI change also waits for my OK on its mocks, and a high-risk change for my OK on its tests. Bigger features are planned as small slices that each go through phases 2 to 4 on their own, so every pull request can be checked by itself. Everything else is agent work, and in phase 6, alerts and scheduled agents turn what production shows into the next plan.

Docs are the operating system

An agent starts every session with no memory of the last one, so the repository’s docs are its memory. Every session, in any tool, starts by reading one file, AGENTS.md. Claude Code’s CLAUDE.md and Cursor’s rules are short adapters that point back to it, so no tool gets a different version of the rules.

The definition of done is a checklist in the workflow runbook, and every agent works to it:

- [ ] Work is on a feature branch (not main)
- [ ] Risk tier stated in the plan and the report
- [ ] Tests added or updated for new or changed behavior
- [ ] Medium/high: every acceptance criterion has a passing test or named check, and the test lock check passes
- [ ] Focused verification completed, with limitations reported
- [ ] Medium/high: independent review done; its findings fixed or reported
- [ ] Docs updated if behavior, architecture or workflows changed
- [ ] Change-matrix rows satisfied
- [ ] Local result reported; delivery steps only if requested

One agent, one worktree, one port slot

Parallel agents only help if they can’t trip over each other, so every task gets its own git worktree: a separate checkout on its own branch. Opening a session in a new worktree runs the setup script automatically. As I write this, 34 worktrees sit beside the main checkout.

Each worktree also runs its own full stack on its own port slot, so an agent tests its change against its own server and never against another agent’s.

ServiceMain checkout (slot 0)Worktree (slot N)
Dev dashboard44444444 + N
API server30043004 + N×100
React dev server51735173 + N×100
End-to-end test server51745174 + N×100

A worktree claims the first free slot from 1 to 9 and keeps it across restarts. A registry in the shared .git folder stops two worktrees from claiming the same slot.

Agents start and stop services only through the dev dashboard or its script, never with a bare dev command, because the dashboard owns the recorded API responses and setting overrides a test depends on. When a pull request merges, a cleanup script stops that worktree’s processes, frees its ports and removes the checkout and its branch.

Tests first, then a second reader

Each change starts with a risk tier, and the tier decides how much process it gets. A copy fix shouldn’t carry the ceremony of a pricing change.

LowMediumHigh
Typical changeCopy, styling, a contained fixA new feature behind its gate, an additive API changeMoney, sign-in, security rules, production data, anything irreversible
Before codeNothing extraAcceptance criteria and mocks of new screen statesCriteria and failing tests, approved by me
TestsA regression test when feasibleWritten first and lockedWritten first and locked
ReviewThe agent checks its own diffA second agentA second agent, then I read the diff

For medium and high risk, the agent writes acceptance criteria as checkable lines, each naming its proof. It writes those tests first and watches them fail for the right reason. Then a small script locks them by recording each file’s hash.

From then on, the agent writing the code can’t change those tests to make them pass. The hook described below refuses the edit, and a final check catches anything that slipped past it. If the agent thinks a locked test is wrong, it stops and tells me why; only I can unlock it, and the reason stays in a log.

When the code is done, a second agent reviews it with fresh context and only read tools, so it can’t quietly fix what it finds. It checks the diff against the criteria and the test results. It reports only gaps that break a criterion or correctness, each with a file, a line and the failing input, never style.

The first change through this review was that hook itself. The reviewer found seven gaps, including a rule that blocked a harmless local test-database setup and one that refused any commit whose message mentioned the lock file. All seven were fixed before it merged, each code fix with a new test.

Phones, simulators and a car on the desk

A web change also runs inside both phone apps, and the native shells add Bluetooth, store billing, ads and the camera. So agents prove a change on Android emulators, iOS simulators and real devices, not just in a browser, across three machines.

MachineIts job
Windows PCThe hub. The Claude Code and Codex sessions run here, with each worktree’s stack, the Android builds, and adb to emulators and a Galaxy phone.
Mac miniBuilds, tests, signs and uploads the iOS app, driven from the PC over SSH. It runs the simulators, mirrors a real iPhone, and has its own Claude session the PC’s agents can message.
Laptop or phoneWhere I talk to the agents on the PC, from anywhere.

When I want to try a change myself, which is optional, its worktree gets a private Tailscale link that opens on my own phone, wherever I am. iOS releases follow the same split as testing: the PC starts them, and the Mac mini runs the tests, signs, archives and uploads, with none of it on GitHub Actions.

New features ship dark

Most new features reach production turned off, so merging and deploying code doesn’t change what customers see. Turning it on is a separate, deliberate step that takes effect in seconds and can be undone the same way. The shared settings schema holds 126 gates.

A feature moves through four steps:

  1. Merged dark. The code ships behind a gate that is off for everyone.
  2. On for my account. I switch it on for myself in production through a per-user override. Only my account can read that override and no client can write it. It applies live, with no deploy, restart or reload.
  3. On for everyone. One command through the production-task CLI turns the gate on, for example prod-task shop-ai:on.
  4. Kept with a kill switch. Every live feature keeps a named off switch, such as prod-task shop-ai:off, and AGENTS.md lists it beside the feature.

The AI search box in Shop is a typical case. It turns a shopper’s words into real search filters. Its runbook says to create its usage table, try it on one account, then run shop-ai:on to turn it on for everyone. Three later features depend on it, so shop-ai:off turns them off too.

Experiments use the same idea. One registry in code holds each experiment’s variants and traffic split, and a script syncs it to PostHog. Every client and server event carries every assignment, so a result such as “variant A converted better” is a saved funnel, not a spreadsheet. Each experiment can be forced to one variant or switched off without a deploy.

One door to production

Writing code is cheap and reversible; changing production is not. So nearly every production change goes through one of three scripts, and each has a dry run.

Production writes go through one CLI. SQL migrations, admin jobs and gate flips all run through prod-task.ps1.

The rules that can’t bend are enforced, not just written. Most of the workflow is guidance an agent weighs. A short list has no exceptions, among them a commit or push to master, a raw migration, a dev server started outside the dashboard and an edit to a locked test. In Claude Code, a hook checks every shell command and file edit against that list before it runs, refuses a match and says why. It adds about 0.16 seconds a call.

Releases are a separate, pinned step. Merging to master deploys nothing. A release happens only when I ask for one:

  1. The agent pins the master commit at that moment in its own release worktree.
  2. A script works out which checks that release’s changes need and runs them, mostly on my desktop.
  3. It pushes that exact commit to the production branch, which is the only thing that deploys.
  4. The server and web deploys end by checking that production is serving that exact commit, and fail if it isn’t within 10 minutes. The third script rolls back to the previous release in one command and reruns no checks, because that release passed them when it shipped. I approve a rollback as part of each release request.

Other agents keep merging while a release validates. Their work isn’t in it and never blocks it; it ships with the next one. A release takes 12 to 30 minutes to validate and promote, depending on what changed.

CI runs when I release, on my desktop. Pull requests and merges trigger no CI; with one engineer, a hosted run on every pull request mostly repeated checks the agent had already run. React checks on pull requests had been the largest Actions cost, $27 of $67 in a month over 123 runs, so they run on my PC, and only for a release. GitHub Actions keeps what must not depend on my desktop being on: the production deploys and scheduled jobs like membership reconciliation.

Agents on a schedule

Four agents run on a schedule without me starting them, and each one’s procedure spells out what it may do alone and what must wait for me. They run on my desktop; when it’s off, alerts in PostHog, Sentry and Azure email me instead.

AgentRunsDoes on its ownWaits for me
Error monitorHourly, while my desktop is onWatches a likely blip for up to 3 runs before acting; ships logging-only changes, at most 1 a run and 3 a day; adds noise filters to alerts; creates at most 1 missing alert per incidentEvery fix that changes behavior stops at a pull request; alert thresholds; it never disables an alert
Server-rendering cost monitorEvery 6 hoursAs a backstop, scales the server-rendering service down if projected spend reaches $50 a dayRolling back the rendering cutover after a traffic drop
Store-rank guardrailDaily, for 30 daysChecks signed-out store search ranks after a title changeReverting the title
Ad scale testMondaysReads install cost and 14-day value per install against the campaign’s kill lineScaling, holding or reverting the campaign

I also schedule one-time check-ins when I start something with a result to judge later. One reviewed a 10% ad rollout after five days and recommended 50%, hold or off; another ran a purchase test I had written down in advance.

Two rules keep these useful:

Closing the loop

A release isn’t the end of a change. Two things carry what happens next back into planning: evals, which measure the AI features with a real model before a change ships, and observability, which shows what production actually did. With the release steps, that is the loop DevOps teams draw from plan to monitor and back, and phase 6 of the diagram is its return path.

Evals put a number on every AI change. Unit tests mock the model, so on their own they can’t show that a model swap or a prompt edit made the answers worse. pnpm eval runs the production code with a real model on frozen, labelled cases, scores the answers in code rather than with a judging model, and reports accuracy, cost per call and latency. A change to a model, a prompt or an output limit runs it on the old and the new setting and puts both results in the pull request.

The first run found a live bug. Production’s VIN photo scan ran Claude Sonnet 5, which thinks before it answers, with room for only 200 tokens of output. On registration cards that show both a plate and a VIN, it ran out of room every time before its answer was complete, and the person saw “Couldn’t read that photo right now.” With room for 1,000 tokens, the same 29 test photos went from 89.7% to 100% read correctly. The fix shipped with the next release.

Then production’s numbers overruled the eval. Its first advice was to move the scan to Claude Haiku 5.5 with thinking off, which read every test photo right for about a twentieth of the cost. But the server logs showed 172 VIN scans in 30 days, about $0.90 a month on Sonnet 5, so switching would save under a dollar a month, judged on drawn test photos with none of the glare or blur of real ones. So the advice is to keep Sonnet 5 and revisit when cost matters: at 1,000 scans a day it would be about $150 a month.

Observability shows what a release actually did. Server errors go to Sentry, client errors and product events to PostHog, and request logs to Azure. Alerts email me when the site goes down, when purchases, pageviews or decodes fall, and when checkouts fail, whether or not my desktop is on. The six scheduled GitHub jobs and the hourly error monitor also check in to Sentry Cron monitors, so one that fails, overruns or never runs raises its own alert. That matters because a job can succeed while doing nothing, and the error monitor runs only while my desktop app is open, which works out to about 45% of hours.

What isn’t built yet is scoring real use as it happens: a signal for how often people correct a scan, and AI spend in production telemetry, are planned. Until then the loop closes through me and the scheduled agents: a finding becomes a test, a rule, an alert or an eval case, and the next plan starts from it.

What broke and what I changed

Agents rarely fail loudly. They fail where two correct pieces meet, or where a default is quietly wrong, so most of my fixes are checks at those seams, written down where the next agent will read them.

When (2026)What brokeWhat I changed
Oct 29A release refused to start. A merged pull request had added the first shared GitHub Actions step, in a folder the release planner had no rule for, and an unknown path blocks a release by design. Nothing shipped.The planner now treats that folder like the workflows that use it, with a test that fails without the rule. The release went out the same afternoon.
Oct 28Nothing had failed yet, but an audit against published agent practice found the gap: every rule was an instruction the agent weighed, nothing refused a forbidden command outright, and the agent writing the code could also edit the tests that judged it.Rules with no exceptions moved into a hook that refuses the command. Acceptance tests written before the code are locked by hash. Medium- and high-risk changes get a read-only reviewer.
Oct 23Three features built in parallel merged with no git conflicts and still collided: one CSS class defined three ways, one helper extracted into two files, and a new data field missing from a registry. Each piece’s own tests passed.After combining parallel work, agents search the combined diff for names added more than once. Then they run the full server unit suite (about 5,300 tests, one minute) and the web type check before opening pull requests.
Sep 29An agent stopped another agent’s dev server because its process filter matched a port number. The test runner had also quietly reused that server, so its screenshots showed another branch’s code.Agents match processes only by their own worktree’s full path, and one-off test setups refuse to reuse a running server. Each worktree already had its own test port.
Sep 2026 of 49 worktrees held uncommitted changes, about 120 files in all, and almost none had a commit. Git listed those branches as merged because an empty branch has nothing left to merge.The cleanup script labels every worktree clean, uncommitted or unmerged, and won’t delete the last two without -Force. Every merge ends by cleaning up its worktree.
Sep 16A cost fix nearly caused a revenue leak. Reading settings on every request had grown database egress from 0.19 to about 2 GB a day. A cache fixed that, but one that returned an empty value would have made every paid report section free, with no error logged.The cache never invents an empty value. Its first read is a real fetch whose error propagates, and a dead listener drops the cache. That rule lives in the code, its tests and ADR 0033.

What I’d tell a team adopting this

Most of this carries over to a team of any size, because the scarce thing is the same: the attention of the people who decide what ships.