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.
| Measure | Count | How I counted |
|---|---|---|
| Pull requests merged | 1,235 | Monorepo, June 13 to October 28, 2026 |
| From agent branches | 1,028 (83%) | Branches named agent/, codex/, claude/ or monitor/ |
| Test files | 1,530 | Every test file in the monorepo except a separate side-project game |
| Runbooks | 113 | docs/runbooks |
| Architecture decision records | 46 | docs/decisions |
| Feature gates | 126 | Gate names in the shared app-settings schema |
| Agent skills | 14 | Step-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
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.
- AGENTS.md is a routing table. It has one row per feature, naming the doc to read, the feature’s gate, whether it is live, and its kill switch. An agent finds the right runbook before it opens any code.
- docs/ is the source of truth. It holds 113 runbooks, 46 architecture decision records and the architecture docs. A change that alters behavior updates its doc in the same pull request, and a change matrix lists what else must change with it.
- Skills hold the procedures. Fourteen step-by-step skills cover work like deploying, triaging production errors, changing a membership plan and removing a finished worktree. Agents load one only when the task needs it.
- Scope is written down. A request to implement allows local work and focused tests. Commits, pull requests, full test suites, merges and deploys each need me to ask for them, so an agent can’t decide to ship.
- Routine delivery goes to a cheaper model. Pulling, merging, cleanup and production promotes go to a lower-cost agent with a short, self-contained brief.
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.
| Service | Main checkout (slot 0) | Worktree (slot N) |
|---|---|---|
| Dev dashboard | 4444 | 4444 + N |
| API server | 3004 | 3004 + N×100 |
| React dev server | 5173 | 5173 + N×100 |
| End-to-end test server | 5174 | 5174 + 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.
| Low | Medium | High | |
|---|---|---|---|
| Typical change | Copy, styling, a contained fix | A new feature behind its gate, an additive API change | Money, sign-in, security rules, production data, anything irreversible |
| Before code | Nothing extra | Acceptance criteria and mocks of new screen states | Criteria and failing tests, approved by me |
| Tests | A regression test when feasible | Written first and locked | Written first and locked |
| Review | The agent checks its own diff | A second agent | A 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.
| Machine | Its job |
|---|---|
| Windows PC | The 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 mini | Builds, 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 phone | Where I talk to the agents on the PC, from anywhere. |
- Android: the real app on the phone loads the worktree’s own web build over adb, and Playwright drives the app’s WebView like a browser. The phone’s screen is mirrored to the PC, so I can watch or sign in.
- iOS: the PC sends its checkout to the Mac mini, which builds it, opens the app at a given page in a simulator and sends back a screenshot. For a tap-through flow, a PC agent asks the Claude session on the Mac. A real iPhone shows on the Mac through iPhone Mirroring, which I watch from the PC.
- The car: OBD-II features are tested against a hardware car emulator on USB with a real Bluetooth adapter plugged in. A script sets speed, temperatures, trouble codes and the VIN, so a check-engine case is a command, not a drive. Recorded adapter sessions replay in unit tests.
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:
- Merged dark. The code ships behind a gate that is off for everyone.
- 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.
- On for everyone. One command through the production-task CLI turns the gate on, for example
prod-task shop-ai:on. - 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.
- It injects production secrets from Doppler into that one process only, so the agent never handles a connection string or token.
- It prints which database it is about to touch and redacts tokens from anything it echoes.
- The repository’s checked-in Claude Code settings pre-approve this script and nothing else that writes to production. In Claude Code, a hook refuses a raw migration or a direct admin-job call, and a secret read still stops for approval.
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:
- The agent pins the master commit at that moment in its own release worktree.
- A script works out which checks that release’s changes need and runs them, mostly on my desktop.
- It pushes that exact commit to the production branch, which is the only thing that deploys.
- 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.
| Agent | Runs | Does on its own | Waits for me |
|---|---|---|---|
| Error monitor | Hourly, while my desktop is on | Watches 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 incident | Every fix that changes behavior stops at a pull request; alert thresholds; it never disables an alert |
| Server-rendering cost monitor | Every 6 hours | As a backstop, scales the server-rendering service down if projected spend reaches $50 a day | Rolling back the rendering cutover after a traffic drop |
| Store-rank guardrail | Daily, for 30 days | Checks signed-out store search ranks after a title change | Reverting the title |
| Ad scale test | Mondays | Reads install cost and 14-day value per install against the campaign’s kill line | Scaling, 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:
- Judgment goes to agents; repeatable work stays deterministic. The recall-alert sweep, membership reconciliation and data cleanup are plain scheduled jobs with no model in them.
- No number without a decision attached. I read these reports while doing something else, and every line I don’t need trains me to skip the next one. When the error monitor has nothing for me, it prints one line and archives its own session.
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 broke | What I changed |
|---|---|---|
| Oct 29 | A 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 28 | Nothing 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 23 | Three 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 29 | An 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 20 | 26 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 16 | A 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.
- Design for the reviewer, not the generator. Agents will write more code than anyone can read. Make every change small, dark and reversible, and make it arrive with its own evidence: tests, screenshots, the checks it ran and the ones it skipped.
- Don’t let the author grade its own work. Write the acceptance tests before the code and lock them, so an agent can’t pass a test by changing it. Then have a different agent, with fresh context and only read tools, check the change against those criteria.
- Put the rules in the repo, not in prompts. A rule said in chat is gone next session. A rule in AGENTS.md or a runbook is read every time, by every tool. The few rules that must never break go in a hook that refuses the command, because a written rule is still one the agent weighs.
- Make the safe path the easy path. Agents take the path of least resistance, so pre-approve the one audited way to touch production and let everything else stop and ask.
- Separate merged, deployed and live. When those are three steps, agents can merge all day while a person decides what customers see and when.
- Give scheduled agents budgets, not just instructions. Caps on deploys per run and per day, on paid test calls, and on how long to watch before escalating turn “use judgment” into something I can check.
- Have agents draw before they build. For a new screen, an agent traces where every value comes from, maps each state the screen can be in and mocks it before writing code. Reviewing a mock costs minutes; reviewing the wrong feature costs days.
- Measure the model, then check the measure against production. Tests that mock the model can’t show that a model swap broke something. Run the real model on frozen cases before a change ships, and let production’s own volume and failure rate decide what a better score is worth.
- Fix the system, not the session. When an agent gets something wrong, the fix is a rule, a test or a script, so the next agent can’t repeat it.