Shipping to production what an agent built
Agent code is correct on the happy path and thin everywhere else, so shipping means checking the parts nobody prompted for.
It works on your machine. The demo went well, the happy path is smooth, the branch is green, and you genuinely do not know what this code does at four in the afternoon with real traffic, real data volumes and a third-party API having a bad day.
That gap has a predictable shape. Agent-written code is usually correct on the path you described and thin everywhere you did not. Nobody prompted for the timeout. Nobody prompted for what happens when the list has forty thousand rows rather than four. Shipping is the work of systematically checking the parts nobody prompted for, and because the gaps repeat, you can check them mechanically rather than reading every line hoping something jumps out. For the read itself, Reviewing code you did not write covers the technique.
Pre-flight: what the configuration actually needs
Start with environment variables. This is the failure that takes down a deploy in the first ninety seconds and is entirely preventable: the agent added a config read in a file you skimmed, and it is not set in production.
# 1. Every env var the code actually reads
rg -o --no-filename 'process\.env\.([A-Z0-9_]+)' -r '$1' src \
| sort -u > used.txt
# 2. Every var set in the target environment (however your platform lists them)
printenv | cut -d= -f1 | sort -u > set.txt
# 3. Needed but not set. This must be empty before you deploy.
comm -23 used.txt set.txt
Run the diff in the other direction too; config variables nothing reads are usually a rename the agent did halfway.
Then sweep for things that only work on a laptop.
rg -n 'localhost|127\.0\.0\.1|:300[0-9]|http://' src --glob '!*.test.*'
Every hit needs a reason. Hardcoded ports, a base URL that was fine in dev, an API key pasted inline during debugging that nobody moved out afterwards. Agents do this readily, because in development it works.
Pre-flight: the database
Read every migration line by line yourself. This is the one place where reviewing generated code by hand is non-negotiable, because it is the one change you cannot roll back by redeploying the previous container.
Four things to look for specifically:
- Destructive DDL.
DROP COLUMN,DROP TABLE, a type change that truncates. If the migration drops something, the rollback plan is a restore from backup, and you should know that before you find out. - A missing backfill. A new non-null column with a default is fine on an empty table and a table lock on a large one. A new column that application code reads but no migration populates is a silent stream of nulls.
- Unindexed foreign keys. Agents write the
REFERENCESclause and skip the index constantly, because the schema is valid without it. The query plan is not. - Backwards compatibility. The old code must survive the new schema for the duration of the deploy, and for however long it takes you to decide to roll back. Additive first, remove in a later release.
-- Ships fine. Old code ignores the column, new code reads it.
ALTER TABLE orders ADD COLUMN fulfilment_channel text;
CREATE INDEX CONCURRENTLY idx_orders_channel ON orders (fulfilment_channel);
-- Does not ship with a working rollback: old code still writes this column.
-- ALTER TABLE orders DROP COLUMN legacy_status;
Then count queries on one realistic request. ORM code written by an agent produces N+1 patterns readily, because a loop fetching a relation per item is the most natural expression of the requirement and passes every test with three rows in it. Turn on query logging, hit one endpoint with production-shaped data, and count.
Pre-flight: everything that leaves the process
Every outbound call needs a timeout and a retry limit. Plenty of HTTP clients wait indefinitely unless told otherwise, which turns one slow upstream into your entire connection pool held open.
const res = await fetch(url, {
signal: AbortSignal.timeout(5_000),
headers: { authorization: `Bearer ${token}` },
});
if (!res.ok) throw new UpstreamError(res.status);
Retries need a cap and a backoff. An uncapped retry loop against a struggling service is a denial of service attack that you wrote and deployed yourself.
Check concurrency over anything user-controlled. Promise.all over an array whose length comes from a request body is unbounded fan-out; a user with a large account can open hundreds of simultaneous connections without doing anything unusual. Bound it with a small worker pool or a chunked loop.
Read the logging with two questions in mind. First, is anything secret or personal going into it β full request bodies, authorisation headers, email addresses in error context. Second, are errors being swallowed. A catch block that logs at debug level and returns a default is how a broken integration stays invisible for a fortnight.
Finally, put the new path behind a flag. Not a deploy-time constant; a value you can change without shipping. The point is that the fix for "this is behaving badly" should take thirty seconds and no build. That plus a backwards-compatible migration is what makes a rollback real rather than theoretical.
CI is the gate, and it does not get bypassed
Typecheck, lint, test and build, all green, on every push. This is where the pre-flight stops depending on your discipline at seven in the evening.
name: ci
on: [push, pull_request]
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm typecheck
- run: pnpm lint
- run: pnpm vitest run
- run: pnpm build
The build step matters more than people expect: a dev server and a production build disagree about plenty, including unused imports, environment access at module scope, and anything relying on a gitignored file.
And no bypassing commit hooks. An agent that hits a failing pre-commit hook will reach for the flag that skips it, because from its point of view the goal was to commit. State it plainly in your instruction file: never --no-verify, never disable a lint rule inline to make a check pass, report the failure instead.
Rollout and watch
Nothing you did not write yourself goes out as a big-bang cutover. Dark launch where you can: run the new path alongside the old, log its result, compare, and keep serving the old one until the comparison is boring. Otherwise, a small cohort first: internal accounts, then a slice of traffic, then wider once you have looked.
For the first period after deploy, error rate and latency on the overall dashboard are a starting point, not the check. Aggregate metrics hide a new endpoint doing badly, because it is a rounding error against your existing traffic. Go and look at the specific new code path: its own error count, its own latency, its own query count, the flag's actual usage. Then read a few real requests through it end to end.
Watching the dashboard tells you whether you broke the site. Watching the new path tells you whether the feature works, and those are genuinely different questions.
The pre-flight checklist
- Every
process.envread is set in the target environment; the diff is empty - No hardcoded localhost, ports, or keys outside test files
- Migrations read line by line: no unplanned destructive DDL, backfill present, foreign keys indexed, old code survives the new schema
- Query count checked on one realistic request; no N+1 in list endpoints
- Timeout and capped retries on every outbound call
- No unbounded concurrency over user-controlled input
- Logs contain no secrets or personal data; no errors swallowed into a default
- New path behind a flag you can flip without a deploy
- Rollback rehearsed, not assumed
- CI green on typecheck, lint, test and build, with no bypassed hooks
Keep reading
Structuring a repo so an agent can navigate it
Six concrete changes that stop your agent burning nine tool calls working out where the routes live.
The retry loop: spotting it and breaking it
Two attempts on the same error is the whole budget, and here is exactly what to do when the second one fails.
When to stop the agent and write it yourself
Six signals that another prompt is wasted, and the skeleton-and-fill pattern that beats both retrying and taking over completely.