Practical Vibe Coding: How I Build With AI
I have been building with AI for a while now. Long enough to notice a pattern in how it goes right and how it goes wrong.
You already know the tools. You know what an AGENTS.md is and how it is used. So I am not going to explain the basics. I want to talk about what actually happens when you build real projects this way, and the setup I have settled on to keep it under control.
Two ways it goes wrong
The first is the gentle prompt. You write a soft, high-level ask and let the AI figure out the rest — understand your intent, fill the gaps, build something out of it. This works. For the first three, four, maybe five features it feels like magic. Then it starts to break down. You ask for one change and something unrelated shifts. The output is close, but it is not what you wanted. And you cannot quite point to where it went off.
The second is the opposite. You front-load everything. Pages of context, every decision made in advance, the whole thing mapped before a line of code exists. It is thorough. It is also slow, and half of it is planning for problems you will never actually hit.
Both extremes come from the same instinct — trying to control an AI that does not remember. I wanted a middle ground. Something that keeps the speed but does not fall apart at feature five.
Here is what that looks like for me: a solid AGENTS.md, scoped prompts written against it, plan mode before every build, and a folder of skills for the parts I build over and over.
A solid AGENTS.md
This is the backbone. You know the file, so here is exactly what I put in mine.
A role. One paragraph: "You're an expert React + Rails engineer. You write clean, simple code. You prioritize clarity over abstraction." That paragraph alone changes the quality of everything that follows.
A short overview. What the app is, what it does, who uses it. One paragraph is enough.
The stack. Every library and service, one line each on what it handles. This stops the AI from suggesting alternatives mid-build.
Folder structure. Where screens live, where components go, where state lives.
Styling rules. Your approach, your color tokens, any quirks worth documenting. For example: "Style with Tailwind tokens only, never hard-coded hex values," or "our Button takes a variant prop, not a className." Write it down once, and the AI never gets it wrong again.
Patterns to follow. Anything you'd otherwise repeat across different sessions.
Security policies you follow. The rules the AI is not allowed to break. Where secrets can and cannot live, which flows it must never touch, how auth and permissions are enforced. If there is a right way and a wrong way to handle auth in your app, write it down. Otherwise the AI invents its own way, and it is wrong in a way you only catch in review — or worse, in production.
You don't have to write this perfectly upfront. Write what you know, then update it when something keeps coming up. When the AI kept reaching for model callbacks, I added a rule to the file. After that, it stopped and used service objects instead. Solve it once, document it, move on.
Here is a stripped-down example you can copy and adapt to your own project:
# AGENTS.md
## Role
You are an expert Ruby on Rails + React engineer working on this
project. You write clean, simple code. You prioritize clarity over
abstraction. If anything is unclear, ask before implementing.
## Overview
A Rails API backend serving a React single-page app over GraphQL.
Users are small-business owners managing invoices and payments. The
backend owns all business logic; the frontend is a thin client.
## Stack
- Ruby on Rails — API-only backend
- graphql-ruby — GraphQL schema and execution
- PostgreSQL — primary database
- Sidekiq — background jobs
- Pundit — authorization policies
- React — frontend UI
- Apollo Client — GraphQL client and caching
- Vite — frontend build tooling
- RSpec — backend test suite
## Folder structure (Rails backend)
- `app/models/` — Active Record models, data only
- `app/graphql/types/` — GraphQL type definitions
- `app/graphql/mutations/` — one class per create/update/delete
- `app/graphql/resolvers/` — query resolvers, one field per class
- `app/services/` — business logic (service objects)
- `app/policies/` — Pundit authorization policies
- `app/controllers/` — thin; the GraphQL controller is the entry
- `db/` — schema and migrations
- `spec/` — RSpec tests, mirrors app/ structure
## Folder structure (React frontend)
- `src/components/` — reusable UI, no business logic
- `src/pages/` — route-level views
- `src/graphql/` — queries, mutations, generated types
- `src/hooks/` — custom hooks
- `src/lib/` — Apollo client setup and helpers
## Conventions and quirks
- Business logic lives in service objects, never in model callbacks.
Model callbacks are considered an antipattern in this codebase.
- Controllers and resolvers stay thin — they call a service and return.
- Styling uses Tailwind tokens only (`bg-primary`, `text-muted`).
Never hard-code hex values.
## Security policies
- Every mutation and resolver authorizes the current user through a
Pundit policy. Never skip the policy check.
- Never expose secrets or keys. Use Rails credentials / ENV on the
backend; nothing sensitive reaches the React bundle.
- The frontend never trusts client input for authorization — the
backend is the source of truth.
## Patterns to follow
- Each GraphQL field returns through a resolver, not inline in a type.
- N+1 queries are handled with GraphQL::Batch loaders.
- Every mutation returns the record plus a `errors` array.
- Keep files small. If a class or component grows unwieldy, split it.
## Skills
Reusable skills for recurring work live in `skills/`. Read the
relevant one before building that kind of feature:
- `skills/graphql-mutation.md` — how to add a mutation end to end
- `skills/service-object.md` — service object conventions
- `skills/react-page.md` — how to scaffold a page with Apollo
Writing the actual prompt
Your AGENTS.md sets the stage. The prompt is what you write for each thing you build. Most people type a couple of sentences and hope the AI fills in the rest. Sometimes it works. More often it comes back close but wrong, or it quietly breaks something that was working.
Every prompt I write has the same bones:
- Point it back to the file. Open with "Read the AGENTS.md and follow it strictly." Every time. The AI does not remember what it read last session, so this pulls the rules back into context before it writes a line. And "strictly" matters — it signals the rules are not suggestions.
- One task. One feature, one endpoint, one page. Merge three into one prompt and you cannot tell which one broke.
- Constraints that protect what works. Describe behavior, not code. What must stay the same, what is allowed to change, what is off limits.
- A reference when it helps. For anything visual, attach a screenshot. The AI reads a layout far better than a description of one.
After that, the prompt changes shape depending on what you are building. Here are the three cases I hit most.
If it's behavior only — no UI, no new gems
Some tasks never touch the screen and need no setup — an auth rule, a permission check, a state change. There is nothing to attach. So describe the behavior and let the AGENTS.md handle the how. Keep it generic and let the file carry the conventions.
Read the AGENTS.md and follow it strictly.
Add authorization so only the owner of an invoice can edit or delete it.
Enforce it in the Pundit policy and in the GraphQL mutation. A non-owner
should get a clear "not authorized" error, not a 500.
Do not change any UI. Do not add new gems.
Read it top to bottom and you can see the structure. The first line points it back to the file. The Add authorization… line is the single task — one thing, not three. The last two lines are the constraints, and they are doing the quiet work: they stop the AI from redesigning a screen or pulling in a dependency you never asked for. Task says what to build; constraints say what to leave alone.
Failure mode: "add permissions to invoices." Too vague. The AI guesses where to enforce it, maybe adds a gem, maybe touches the UI — and you spend twenty minutes finding out what it decided.
If it needs setup — paste the guide
Some tasks need a new library or a config step, and this is exactly where the AI is most likely to be wrong. Its training data is months behind, so it reaches for a deprecated API or an old setup pattern. The fix is to paste the current guide straight into the prompt, under a divider.
Read the AGENTS.md and follow it strictly.
Add Sidekiq for background jobs. Wire it into the app and move the
invoice-email send into a background job. Keep the existing mailer
behavior identical.
---
[paste the current Sidekiq setup guide here]
The AI reads your instructions first, then follows the guide for the parts that change between versions. If the library ships an official skill, install it once and you can skip the paste.
Failure mode: trusting the AI's memory for setup. It writes something that looks right, and you find out it is a version behind at build time — or in production.
If it's a UI page — attach a screenshot
When the task is a page with a few interactions, a screenshot does more than a paragraph of description ever will. Attach it, name the actions, and say what not to add.
Read the AGENTS.md and follow it strictly.
Build the invoices dashboard page as shown in the attached screenshot.
Wire the "New invoice" button to the create flow, and the row menu to
edit and delete. Use the existing Apollo queries in src/graphql.
Do not add filters or pagination — they are not in this design.
[attach screenshot]
The screenshot handles the layout. The constraint keeps the AI from inventing features that are not in the design.
Failure mode: describing a layout in words. You write three paragraphs about spacing and columns, the AI approximates it, and you iterate five times on something one image would have settled.
The one line under all of it
A good prompt is an instruction with a defined scope. The scope is three things: what you are building now, what you are not touching, and what the AI already knows from the file. Once those are decided, the prompt almost writes itself. The file handles the rules, the task line handles what is new, the constraints handle what is protected, and a screenshot or a guide goes in when the task needs it.
Plan mode before you build
I run almost everything through plan mode first, and I would tell any engineer to do the same.
Plan mode is great precisely because it shows you what the AI is thinking before it touches anything. Where the code is going to go. What files it wants to change. What data it is going to move, and what the shape of it will be. You get to read the intent instead of reading a diff after the fact.
And that is the point where you have leverage. If something in the plan is not up to the mark — wrong file, wrong approach, a shortcut you do not want — you correct it right there, before a single line is written. It is far cheaper to fix a plan than to unwind a build.
Here is what that looks like. Before a backend change, I do not ask for code — I ask for the plan:
Read the AGENTS.md and follow it strictly.
Plan how you would add soft-delete to invoices. Do not write any code
yet. List the files you would touch, the migration, and where the
filtering would happen.
It comes back with an approach: a migration to add deleted_at, a default scope on the model, changes to three resolvers, and a Pundit tweak. Reading it, I catch the part I do not want — a default scope hides deleted records everywhere and will bite me in a report six weeks from now. So I correct it right there: "Don't use a default scope. Filter in the resolvers instead." Then I let it build. That fix was one line, before a single file changed. Caught in a diff, it would have been an afternoon.
So the habit is simple. Start in plan mode. Read what it intends to do. Adjust until the plan is right. Then let it execute.
Skills for the parts you build again and again
This is the piece that changed the most for me recently.
Every project has work you repeat — the same shape of form, the same kind of GraphQL mutation, the same table. Each time you build it by prompt, you re-explain the same context and hope you did not drop a detail. A skill writes that context down once, so the agent stops improvising and follows the pattern you already settled on.
A skill is one file for one kind of work. I keep each in its own folder — skills/<name>/SKILL.md — with a short "when to use," the steps to follow, and one worked example. The example is the part that matters. Give the agent a version of the thing done exactly the way you want it, and it copies the shape, not just the words.
Take a form I have written a dozen times — a new model with a single-step create/edit form, wired the same way every time. Instead of re-explaining it on every feature, I wrote it down:
# create-form
description: Use when a new model needs a single-step create/edit form,
following our standard React Hook Form + GraphQL structure.
## When to use
A new model that opens one create or edit screen — a handful of fields,
save and cancel. Not for multi-step wizards.
## Steps
1. Generate the create/update mutations in src/graphql.
2. Build the form with FormProvider and useFormContext.
3. Wire the mutation hook; update the Apollo cache on success.
4. Handle three states: submitting, validation errors, server errors.
## Example
See VisitForm below — copy its structure exactly.
The Example section is where the golden reference lives — a complete, correct version of the component that I am happy with:
export function VisitForm({ visit }) {
const methods = useForm({ defaultValues: toFormValues(visit) })
const [save, { loading }] = useSaveVisit()
const onSubmit = (values) =>
save({ variables: { input: toInput(values) } })
return (
<FormProvider {...methods}>
<Form onSubmit={methods.handleSubmit(onSubmit)}>
<TextField name="title" label="Title" required />
<DateField name="scheduledAt" label="Date" required />
<FormActions submitting={loading} />
</Form>
</FormProvider>
)
}
Then you register it, or the agent will never find it. A skill nobody opens is dead weight. I keep an index — a plain skills.md that lists every skill, its path, and a one-line "use it when." That index is the routing table:
# Skills
| Skill | Path | Use it when... |
| ------------ | ---------------------------- | -------------------------------------- |
| create-form | skills/create-form/SKILL.md | Adding a model with a single-step form |
| graphql-crud | skills/graphql-crud/SKILL.md | Adding a backend mutation end to end |
| stack-table | skills/stack-table/SKILL.md | Building a list or table view |
And you map that index from AGENTS.md, so the first file the agent reads points it at the right skill:
## Skills
Before building a recurring piece, check `skills.md` and read the
matching `SKILL.md` first. Follow the most specific skill; fall back to
these conventions only when none applies.
Now the loop closes. The agent reads AGENTS.md. It sees it is about to build a visit-flow form. It opens create-form, reads the golden example, and builds the new one in that exact shape — associations, validation, cache update and all. No re-explaining. On one project I have around thirty of these, and the jump in consistency is hard to overstate.
Think of it as three layers: the AGENTS.md is how the whole project works, each skill is how one recurring piece should be built, and skills.md is the routing table between them.
When something breaks
It will. That is not the method failing, that is just software.
When it happens, I do not re-explain the feature or paste the codebase. One targeted fix. State the problem, state the correct behaviour, add a constraint to keep everything else untouched. One problem, one fix, one check that it worked.
Conclusion.
The AGENTS.md tells the agent how the project works. You write a scoped prompt for the one thing you are building — a task, the constraints that protect what already works, and a screenshot or a guide when the task needs it. You run it through plan mode and fix the approach while it is still just words. And when the work is something you have built before, a skill hands the agent the pattern so it does not reinvent it.
Then you build one feature, check that it works, and move to the next. When something breaks, it is contained, because you only changed one thing.
That is the whole method.