Using AI Coding Agents Without Losing Code Quality
AI agents are like that enthusiastic junior dev who ships fast
You know the feeling. You ask an AI coding agent to fix one bug, and it comes back with a beautiful diff that also rewrites your auth system, updates your lock file, adds three new dependencies, and renames every variable to `temp`. It looks great. It compiles. It might even pass some tests. But it also just broke two things you weren't working on.
That's the core problem with AI coding agents: they're very good at looking done. But "looks done" and "is done" are very different things in software.
The good news? You don't have to stop using agents. You just have to give them a leash. A short one.
This article shows you a simple TypeScript quality gate that keeps agents on track. It checks five things:
- Is the task clear enough to review?
- Did the agent only touch the allowed files?
- Did each requirement get checked?
- Did a human actually run the checks?
- Did a human review the results before merging?
If any answer is "no," the gate says "stop." No magic, no agent SDK — just TypeScript and a bit of common sense.
How this works (the 30-second version)
You write a small, clear task
|
v
Give the agent just the info it needs
|
v
Agent proposes a change
|
v
You check the files, run the tests, read the diff
|
v
You sign off — or send it back with notes
Words you'll see a lot (explained simply)
- Task boundary: the job the agent is allowed to do — and the stuff it must not touch.
- Acceptance criterion: a sentence that says "we're done when this is true."
- Allowlist: the exact files the agent is allowed to change. Nothing else.
- Evidence: proof that something was actually checked — not just "the agent said so."
- Sign-off: a real human saying "I looked at this and it's good."
1. Tell the agent what to do (and what NOT to do)
"Fix the health endpoint" is a terrible task description. It's like telling someone to "improve the kitchen." They might repaint it, rip out the counter, or install a swimming pool.
Good task descriptions are specific:
Bad: "Improve the health endpoint."
Good: "Add a one-minute cache header to the health endpoint.
Keep the response body and status the same.
Only change these two files."
Every good task has five ingredients:
- What to do (the outcome, in one sentence)
- How much the agent can change (scope)
- What NOT to change (out-of-scope list)
- How to know it's done (acceptance criteria)
- Which files are allowed to change (the allowlist)
That "out of scope" list isn't bureaucracy. Without it, the agent will "helpfully" upgrade your dependencies, rewrite your CSS, and refactor your database — all while fixing that one typo in a comment.
2. Give the agent just enough context
Think of context like ingredients for a recipe. Too few and the agent guesses. Too many and it gets lost.
Give it the target file, nearby tests, and the project rules that matter. Don't dump the whole repo.
Target: app/api/health/route.ts
Tests: app/api/health/route.test.ts
Rules: Use the existing response helper. Don't add dependencies.
Files: Only these two files may change.
Evidence: Report what you did. Don't approve your own work.
If a fact matters to correctness, put it in the task — don't hope the agent discovers it.
3. Put it in a type (yes, this is the TypeScript part)
Here's the contract, as a TypeScript type. Every field has a purpose:
export type TaskSpec = {
id: string // stable name for this task
title: string // human-readable title
objective: string // one sentence: what "done" looks like
inScope: string[] // what the agent may decide
outOfScope: string[] // what it must not touch
acceptanceCriteria: AcceptanceCriterion[] // checkable facts
allowedFiles: string[] // the exact files that may change
verification: VerificationRequirement[] // what must be verified
}
And here's a concrete example — a real task you could give an agent:
const task: TaskSpec = {
id: 'add-cache-header',
title: 'Add a cache header to the health route',
objective: 'Make health responses cacheable for one minute.',
inScope: ['The health route response headers.'],
outOfScope: ['Database changes.', 'UI changes.', 'New dependencies.'],
acceptanceCriteria: [
{ id: 'header', description: 'Successful responses include max-age=60.' },
{ id: 'unchanged-body', description: 'The response body and status stay the same.' },
],
allowedFiles: ['app/api/health/route.ts', 'app/api/health/route.test.ts'],
verification: [
{
id: 'tests',
description: 'Health route unit tests pass.',
expectedEvidence: 'A human ran the test command and recorded the result.',
},
{
id: 'diff',
description: 'The final diff contains only the allowed files.',
expectedEvidence: 'A human inspected the changed-file list.',
},
],
}
4. Check the task before you check the code
If the task has no acceptance criteria or no allowed files, it's not ready for an agent. The gate catches this before looking at any code.
export function validateTaskSpec(spec: TaskSpec): QualityReport {
const failures: QualityFailure[] = []
if (!nonEmpty(spec.id)) failures.push(failure('Task id is required.'))
if (!nonEmpty(spec.title)) failures.push(failure('Task title is required.'))
if (!nonEmpty(spec.objective)) failures.push(failure('Task objective is required.'))
if (spec.inScope.length === 0) failures.push(failure('In-scope items are required.'))
if (spec.outOfScope.length === 0) failures.push(failure('Out-of-scope boundaries are required.'))
if (spec.acceptanceCriteria.length === 0) {
failures.push(failure('At least one acceptance criterion is required.'))
}
if (spec.allowedFiles.length === 0) failures.push(failure('Allowed files are required.'))
if (spec.verification.length === 0) failures.push(failure('Verification requirements are required.'))
return { ok: failures.length === 0, failures }
}
This prevents the "I gave the agent a vibe and it went wild" scenario.
5. Check which files actually changed
Before reading the diff, look at the list of files. A change can be perfect code in the wrong place.
const allowedFiles = new Set(spec.allowedFiles)
for (const file of changedFiles) {
if (!allowedFiles.has(file)) {
failures.push({
code: 'scope-violation',
message: 'Changed file is outside the allowlist: ' + file,
})
}
}
Here's what a scope violation looks like in practice:
You asked for: route.ts + route.test.ts
Agent changed: route.ts
route.test.ts
README.md <-- wait, who invited you?
That README change might be a nice improvement. It's still outside this task. Stop and decide: either update the task to include it, or tell the agent to remove it.
6. Evidence is not "trust me, bro"
The agent can say "all tests pass." That's nice. But it's not proof — it's a claim.
The quality gate records three things for every check: what was checked, whether it passed, and who checked it.
export type VerificationEvidence = {
requirementId: string
status: 'passed' | 'failed' | 'not-run'
source: 'human-run' | 'agent-reported'
note: string
}
The gate only accepts `source: 'human-run'`. Agent-reported evidence gets rejected:
Agent says "tests pass" -> interesting, but not proof
You run the tests -> that's evidence
You check the diff -> that's more evidence
The full check looks like this:
for (const requirement of spec.verification) {
const item = evidenceByRequirement.get(requirement.id)
if (!item || !nonEmpty(item.note)) {
failures.push({
code: 'missing-evidence',
message: 'Missing evidence for verification: ' + requirement.description,
})
continue
}
if (item.status !== 'passed') {
failures.push({
code: 'failed-verification',
message: 'Verification did not pass: ' + requirement.description,
})
}
if (item.source !== 'human-run') {
failures.push({
code: 'untrusted-verification',
message: 'Verification must be reviewed by a human: ' + requirement.description,
})
}
}
This is the most important rule: the agent may say what it tried. It may not say what passed. That's your job.
7. Run the tests yourself, then ask these questions
The gate's tests cover the cases that matter most:
- A valid task with human-reviewed evidence
- A task with missing acceptance criteria
- A file changed outside the allowlist
- Failed test evidence
- "The agent said it passed" evidence
Here's one of those tests:
test('rejects failed verification evidence', () => {
const evidence = passedEvidence.map((item) =>
item.requirementId === 'tests' ? { ...item, status: 'failed' as const } : item
)
const report = reviewChange(task, ['app/api/health/route.ts'], evidence)
assert.equal(report.ok, false)
assert.ok(report.failures.some((failure) => failure.code === 'failed-verification'))
})
After running the tests, read the actual diff. Ask yourself:
- Are the changed files exactly what I expected?
- Does every acceptance criterion have a test or a manual check?
- Did I run the tests, or am I taking the agent's word for it?
- Does the code only do what the task asked?
- Is the error handling safe?
- Can I explain every line in the diff?
The quality gate in one picture
Task clear? no -> add the missing details
|
yes
v
Files allowed? no -> stop and review the scope
|
yes
v
Checks passed? no -> fix the code or record the failure
|
yes
v
Human reviewed? no -> do not merge yet
|
yes
v
Ready for a merge decision
8. A real-ish example (okay, it's simulated)
We didn't actually have a rogue agent in production for this article. But here's a realistic scenario:
The task: add a cache header to the health route. Change only the route and its test.
What the agent did (simulated): added the cache header (good!), but also edited README.md to document it (bad!) and said "all tests pass" without a human running them (worse!).
What the gate caught:
- `scope-violation` — README.md was not in the allowlist
- `untrusted-verification` — the agent approved its own work
The fix: remove the README change, run the tests yourself, record both checks with `source: human-run`. Only then does the report go green.
The lesson: "it works" is only half the story. "It was the right task, and someone checked" is the other half.
9. The human signs off (or it doesn't ship)
The last step is a person making a decision. Not another prompt. Not another agent.
A simple checklist:
[ ] I read the task boundary and it's specific enough.
[ ] The changed-file list matches the allowlist.
[ ] Every acceptance criterion has evidence.
[ ] I ran the tests against the final change.
[ ] I looked at the diff for anything unexpected.
[ ] I didn't run any agent-supplied commands without reviewing them.
[ ] I understand what could go wrong.
[ ] I approve this change for merge.
An agent can prepare a review summary, highlight suspicious lines, and suggest next steps. But it should never be the one saying "ship it." That's your call.
When NOT to use an agent
Some jobs need a human at the keyboard the whole time:
- Passwords, permissions, and anything security-related
- Database migrations (one wrong `DROP` and your weekend is ruined)
- Money, health, or legal calculations
- Production incidents (when things are on fire, don't hand the hose to a robot)
- Secrets and private customer data
Also skip agents when the task is too fuzzy to describe clearly, or when the review would take longer than just doing the one-line fix yourself.
What this gate doesn't prove
This gate checks that the task is well-defined, that only allowed files changed, and that a human verified the results. It does not guarantee the code is perfect, the tests are comprehensive, or that the human reviewer caught every bug.
That's intentional. A good gate shows you its blind spots instead of printing a false "all good" badge. Add more checks (type safety, security reviews, broader test coverage) when the risk justifies them.
The 7-step workflow (your cheat sheet)
- Write a small, clear task with boundaries and acceptance criteria.
- Give the agent only the context it needs.
- Ask it to inspect files before editing, and to list changes before making them.
- Review the diff and the changed-file list.
- Run tests yourself and record what happened.
- Reject anything outside the boundary, with missing criteria, or with only agent-reported evidence.
- Sign off — or send the task back with a tighter scope.
The point isn't to take the fun out of AI coding. It's to put your judgment where it matters most: at the boundary, at the review, and at the merge button.
Further reading: Node.js test runner, TypeScript, Next.js TypeScript, and the OWASP Top 10 for Large Language Model Applications.