I built a lead intake app with an AI Coding Agent. Here is what went wrong, and why that was the point.
A few weeks ago, I built a small full-stack app from scratch: a public form where someone submits their name, email, and a resume. On submit, the system saves the file, stores the record, and sends two emails automatically. Behind a login, a reviewer can see every submission, open the resume, and move it from “pending” to “reached out.”
Nothing about that is exotic. FastAPI on the backend, Next.js on the front end, Postgres for storage, and a local email catcher for testing, all wired together with Docker Compose so the whole thing runs with one command.
The interesting part was not the app. It was the process I used to build it, and the mistakes I caught along the way. Four things stood out enough that I wanted to write them down properly, in order, with the actual details.
1. Plan before you prompt:
Before writing a single line of code, I wrote two documents.
The first was a design doc. It laid out what the app needed to do, the hard technical constraints, and the reasoning behind every technology choice. Why Postgres and not something lighter. Why a background email tool instead of a real provider. Why files live on disk with only their metadata in the database.
The second was a task-wise plan. I broke the whole build into sixteen small tasks, ordered so nothing depended on something built later. Each task had a clear goal, the exact files it should touch, and a specific way to verify it worked.
This step felt slow at the time. It was not. Every hour spent here saved several hours later, because I never had to stop mid-build and ask “wait, what was I even trying to do here?” And it meant that every instruction I gave my coding agent was specific, rather than vague and open to interpretation.
2. Small tasks, fresh sessions, every time:
I used two tools for two different jobs. One AI tool (Claude) for thinking: interrogating the design, weighing tradeoffs, writing the plan, and generating the exact prompt for each task. A separate coding agent (Cursor) for actually writing code, one task at a time.
The important detail is “one task at a time, in a fresh session.” A long agent session accumulates context, and I noticed the output quality drop as it went on. The agent would start forgetting earlier constraints or quietly making its own decisions. Starting a new session for every task, focused only on the design doc and that one task, kept every session focused and the output clean.
Every prompt I sent followed the same shape: read the design doc and this one task, build only this task, do not start the next one, follow the layering rules, run these exact checks and show me the raw output, then stop.
3. Trust, but verify twice:
“The agent says it works” is not the same claim as “it works.” I checked every task two separate ways, and I think skipping either one is how the quiet bugs below would have slipped through.
Behavioral: does it actually do the right thing? I ran the flows myself, clicked through the interface myself in a real browser, and tested what happens on wrong input, not just the happy path.
Structural: is it built the right way? I read the actual route files against a short checklist: no database queries in routers, no business logic in routers, no file handling in routers, responses using their own schema instead of the raw database model.
This two-part check is exactly how I caught three things that mattered.
The first, a timing leak in login. The login flow checks an email and password against one seeded user. The agent’s first version only ran the password check when the email existed. If the email did not exist, it skipped that check and returned immediately.
That sounds harmless until you think about it as an attacker. An unknown email now responds faster than a wrong password for a real email. Measure that time difference enough times, and you can quietly figure out which emails are real, without ever guessing a password. That is a timing side channel, and it is a real class of vulnerability, not a theoretical one. The fix is simple once you see the problem: always do the same amount of work.
# before: skips the password check when the email is unknown
def login(email, password):
user = user_repository.get_by_email(email)
if user and bcrypt.checkpw(password.encode(), user.hashed_password):
return issue_jwt(user.id)
raise InvalidCredentialsError()
# after: always runs one real password check, real or dummy hash
DUMMY_HASH = bcrypt.hashpw(b"not-a-real-password", bcrypt.gensalt())
def login(email, password):
user = user_repository.get_by_email(email)
hash_to_check = user.hashed_password if user else DUMMY_HASH
password_is_correct = bcrypt.checkpw(password.encode(), hash_to_check)
if not user or not password_is_correct:
raise InvalidCredentialsError() # identical error, every time
return issue_jwt(user.id)Both failure paths now take roughly the same time, and both return the exact same generic error. Nothing about the response tells an attacker whether the email exists.
The second, a migration that could not undo itself. Postgres lets you enforce a status field as a real enum type, like “pending” or “reached out,” instead of a plain text column. The first migration created that enum type correctly going forward, but its “undo” step never dropped the enum type it had created. Reversing the migration would leave a leftover type sitting in the database, which would then make a clean re-run fail. This one only shows up when you actually try to reverse a migration and run it again, not on a normal forward run, which is exactly why I made a habit of testing the “wipe everything and start over” path before calling anything finished.
The third, a silent decision about duplicate submissions. The same email address should be able to submit more than once. A person applying for help does not always get it right the first time, and the spec never asked me to block repeat submissions. My agent had, on its own, treated email as something that should be unique in the database. I caught it while reviewing the schema and corrected it before it became a constraint that would reject legitimate resubmissions later.
None of these three were dramatic failures. They were quiet ones, the kind that pass a surface-level “does it run” check and only show up once you go looking for them.
And before calling anything done, I always tested the app from an empty state: wipe every database and file volume, then bring the whole thing back up from nothing. That is exactly what happens the first time anyone else runs your project, and it is where missing migrations or empty folders quietly reveal themselves.
4. Layering is not bureaucracy; it is insurance:
The single rule I protected the hardest, above everything else: keep your layers separate, and never let one skip past another.
Requests only ever move down one layer at a time. A router calls a service, a service calls a repository or an integration, and nothing skips ahead.
In plain terms:
Routers only parse the request, call one service method, and turn the result into an HTTP response. No database queries. No business rules. No file handling.
Services hold all the logic. They decide the order things happen in, and they own the database transaction.
Repositories are the only layer allowed to touch the database. They do not decide anything; they just read and write what they are told.
Integrations, like sending email or saving a file, sit behind their own interface, so the actual email provider or storage location can be swapped later without touching business logic.
It sounds like extra structure for a small project. What it actually buys you is that when something is wrong, there is exactly one place to look, and when you want to change something later- a different email provider, a different storage location- you change one small piece instead of hunting through the whole app.
What I would still add:
Being honest about the gaps is part of the process too:
Pagination on the list view, since right now it returns everything at once
Moving email out of the request and into a background queue with retries
Swapping local disk storage for something like cloud object storage, which the interface is already designed to allow
Checking the actual bytes of an uploaded file, not just its file extension
Basic rate limiting on the public form
Closing thoughts:
The biggest lesson was not a specific bug. It was that an AI agent will happily produce code that runs, looks reasonable, and is quietly wrong in a way that only shows up if you go looking for it. Planning first, building in small verified steps, and checking both behavior and structure is what caught all three issues above. None of them would have shown up from just clicking around once and calling it done.
Code for this project is public here: Link
I’m still early in my software engineering path. My background is in data engineering: pipelines, dashboards, data systems. I’m pivoting into software engineering now, and instead of waiting until I feel ready, I’m building real projects like this one, hitting real bugs, and writing about what I learn along the way. If this way of working resonates with you, or with a team you’re part of, I’d love to connect on LinkedIn: https://linkedin.com/in/agarwal-pratyush/
I’m also putting together a proper site to collect this kind of writing and my other projects, at pratyushagarwal.com, launching July 31, 2026.

