Coding Rails With an AI Agent
A quiet revolution has happened in the last few years: AI coding agents went from "interesting toy" to "the environment most Rails developers now work in". A book that teaches Rails in 2026 can no longer pretend they don’t exist.
This chapter is not "how to prompt". Prompts go stale fast — the tool you use next year will understand you better than the one you use today. What doesn’t go stale is the workflow: what to feed an agent, how to verify what it produced, how to keep the blast radius small when it inevitably gets something wrong.
| Do the Ruby Basics and First Steps chapters by hand first. If you let an agent drive while you’re learning the language, you end up shipping code you don’t understand, and when it breaks at 3 AM, nobody on your team can read it either. Pick up the fundamentals first, then use an agent to go faster. |
| On a first read you can skim this chapter. It mentions concepts such as scaffolds, migrations, routes, Turbo, and test fixtures that only get introduced properly in later chapters. Come back here once you have worked through the ActiveRecord, Scaffolding and Test-Driven Development chapters. Agentic coding is a style of working, not a replacement for the fundamentals. |
Why Rails Is a Particularly Good Fit
Three properties of Rails make it unusually friendly territory for AI agents:
-
Convention over configuration. An agent asked to "add a Product model" knows the controller goes in
app/controllers/products_controller.rb, the model goes inapp/models/product.rb, the test goes intest/models/product_test.rband the migration goes indb/migrate/. There is exactly one right answer to where every piece lives. An equivalent task in a bespoke Node microservice might have ten plausible answers. -
The test suite as an executable spec.
bin/rails testis a single command that tells the agent did I break anything that used to work? That feedback loop is the single biggest multiplier for agent productivity. Rails apps that already have a respectable test suite are a delight to work on with an agent; apps without one are frustrating. -
Hotwire shrinks the JavaScript. Most Rails 8 features are pure Ruby because Turbo and Stimulus do the browser side for you. Agents handle Ruby far better than they handle hand-rolled JavaScript state machines. Every feature you can keep on the server is a feature you can ship faster.
Pick a Tool
For the rest of this chapter I’ll use Claude Code as the
concrete example. It’s a terminal-native agent — you run
claude in your project directory and it can read and edit
files, run commands, and check results. The principles carry
over to any of the other well-known agents:
-
Cursor and Windsurf if you prefer an editor that’s an agent.
-
GitHub Copilot for inline completions plus a chat mode.
-
Aider if you want a small open-source terminal agent.
Pick whichever lets you read the diff before accepting an edit. The specific product matters less than your discipline about verifying what it produces.
Feed the Agent Context
The biggest single factor in getting useful output is what you feed the agent. Rails gives you a lot of context for free.
CLAUDE.md
Claude Code reads a file called CLAUDE.md at the root of
your project on every session. Cursor has a similar file at
.cursor/rules/*.mdc; Copilot uses
.github/copilot-instructions.md. Whichever tool you use, a
short project-specific preamble pays for itself many times
over.
Here’s a minimal one for a fresh Rails 8 app:
# Project
A Ruby on Rails 8.1 application. Ruby 4.0, SQLite in
development and production, Solid Queue / Solid Cache /
Solid Cable.
## Conventions
- Always use `bin/rails`, never `rails`.
- Run `bin/rails test` before declaring a change complete.
- System tests use headless Chrome via Cuprite.
- Use Turbo/Hotwire for interactivity. Only add a Stimulus
controller when the behaviour is genuinely client-side.
- `button_to` for destructive actions, never
`link_to ..., method: :delete`.
- Prefer `params.expect(...)` over
`params.require(...).permit(...)`.
- Never hand-write migration files. Always create them with
`bin/rails generate migration ...` (or a model/scaffold
generator). The generator picks the correct
`ActiveRecord::Migration[8.1]` version stamp and writes a
safe reversible skeleton.
## Do not
- Edit `db/schema.rb` by hand. Write a migration instead.
- Commit `config/master.key` or anything containing real
credentials.
- Skip or disable tests. If a test is wrong, fix it; if the
code is wrong, fix the code.
That file is the cheapest investment in code quality you’ll make this year.
Built-in Context Sources
Anything the agent can run gives it context without you having to paste:
$ bin/rails routes
$ bin/rails runner "puts User.column_names"
$ cat db/schema.rb
$ bin/rails test test/models/product_test.rb
Good agents will run these on their own once they know which
ones exist. The CLAUDE.md above mentions bin/rails test
explicitly; you can add a "Useful commands" section if your
project has less obvious ones (bin/jobs,
bin/rails dev:cache, whatever).
Verify, or You Didn’t Do It
This is the most important section in this chapter.
Agents are trained to sound confident. They will happily tell you "the test suite passes" when what actually happened is they wrote the tests but never ran them. Your job is to distrust the summary and check the evidence:
-
Run the tests yourself. Not "the agent says they pass". Run
bin/rails testand watch the green line. -
Read the diff.
git diffbefore every commit. Every line. If a hunk confuses you, ask the agent to explain it — before accepting. -
Open the page in a browser. Type checkers and test suites prove correctness of code. They do not prove correctness of features. For anything user-visible, look at it.
-
Stage in hunks you’ve reviewed.
git add -pwalks you through each change and asks "keep?". Use it.
I have a small shell alias glog for git log --oneline
-20 and I run it after every agent-assisted session.
Five commits of "WIP", "more WIP", "actually fix
it", "revert" is a signal the loop needs tightening,
not a badge of honour.
|
Small Reversible Steps
The TDD loop the book teaches in Tests is exactly the loop you want with an agent, only faster:
-
Write (or have the agent write) a failing test that describes the behaviour.
-
Ask the agent to make it pass — and only that.
-
Run the tests.
-
Read the diff.
-
Commit.
A commit per logical step keeps git revert a cheap
insurance policy. When an agent takes a wrong turn two hours
in, a git reset --hard HEAD~3 costs you three small steps,
not a day.
For migrations specifically: always bin/rails db:migrate to
run them and bin/rails db:rollback to reverse. Never let an
agent hand-edit db/schema.rb — that file is the output, not
the input.
A Real Example: Claude Code, End to End
Here is an actual session, warts and all, adding a "published" flag to a blog. We start from a completely empty directory so every step is visible.
Setup
Generate a fresh Rails 8.1 app and step into it. rails new
also runs git init and commits the untouched application as
its first commit, so you already have a clean baseline to
revert to:
$ rails new blog
$ cd blog
Generate the Post scaffold that we will extend later, and
run the migration it just wrote:
$ bin/rails generate scaffold post title body:text
$ bin/rails db:migrate
Create CLAUDE.md at the project root. We reuse the template
from the CLAUDE.md section above and prepend a
couple of sentences about this specific project, because the
agent has no way of knowing what "blog" means to us:
# Project
A small personal blog. A `Post` has a `title` (string) and a
`body` (text), scaffolded with
`bin/rails generate scaffold post title body:text`. There is
no authentication yet and the scaffolded CRUD routes are
public.
The application is Ruby on Rails 8.1 on Ruby 4.0, SQLite in
development and production, Solid Queue / Solid Cache /
Solid Cable.
## Conventions
- Always use `bin/rails`, never `rails`.
- Run `bin/rails test` before declaring a change complete.
- System tests use headless Chrome via Cuprite.
- Use Turbo/Hotwire for interactivity. Only add a Stimulus
controller when the behaviour is genuinely client-side.
- `button_to` for destructive actions, never
`link_to ..., method: :delete`.
- Prefer `params.expect(...)` over
`params.require(...).permit(...)`.
- Never hand-write migration files. Always create them with
`bin/rails generate migration ...` (or a model/scaffold
generator). The generator picks the correct
`ActiveRecord::Migration[8.1]` version stamp and writes a
safe reversible skeleton.
## Do not
- Edit `db/schema.rb` by hand. Write a migration instead.
- Commit `config/master.key` or anything containing real
credentials.
- Skip or disable tests. If a test is wrong, fix it; if the
code is wrong, fix the code.
Those first two paragraphs are the whole point: without them
the agent would have to guess what kind of app this is from
the Post model alone, and it would probably assume Devise,
Pundit and a handful of other defaults we do not have.
A quick boot check confirms the app is in working order before the agent touches anything:
$ bin/rails test
Commit the scaffold and CLAUDE.md together. One tidy
starting point makes it obvious later which lines the agent
added:
$ git add .
$ git commit -m "Scaffold Post and add CLAUDE.md"
Now start the agent in the project directory:
$ claude
> /init
/init asks Claude to read the project and summarise its
understanding. This is a good sanity check that it’s seeing
what I expect.
The Ask
I want posts to default to draft and only be listed on the
public index when published_at is set. My prompt:
Add a
published_atdatetime column to Post (default null). Add aPost.publishedscope that returns rows wherepublished_at ⇐ Time.current. Have the index action return only published posts for anonymous visitors; authors keep seeing everything. Write a model test that proves the scope, and a controller test that proves the visibility rule.
Note three things about that prompt:
-
One user-visible behaviour change. Not "build my whole CMS" — a single feature small enough to review in one sitting.
-
The exact schema change. No ambiguity about column name or type.
-
Specifies the tests to write. The agent will be measured by a test it wrote, so the loop closes on itself.
What Claude Did
Claude came back with a plan, then asked to proceed. I said yes, and it:
-
Ran
bin/rails generate migration AddPublishedAtToPosts published_at:datetime. -
Edited
app/models/post.rbto addscope :published, → { where("published_at ⇐ ?", Time.current) }. -
Edited
app/controllers/posts_controller.rb— theindexaction now askscurrent_userand falls back to the published scope when nobody is logged in. -
Wrote
test/models/post_test.rbwith three tests: one unpublished post, one published post, one scheduled-future post. -
Wrote
test/controllers/posts_controller_test.rbadditions checking each case. -
Ran
bin/rails db:migrate && bin/rails test.
Final line of its output: "All 8 tests pass."
What I Did Next
I did not believe it. I ran the tests myself:
$ bin/rails test
Running 8 tests in a single process (parallelization threshold is 50)
Run options: --seed 41230
# Running:
........
Finished in 0.312s, 25.6 runs/s, 38.5 assertions/s.
8 runs, 12 assertions, 0 failures, 0 errors, 0 skips
Good. Then I read the diff:
$ git diff --stat
app/controllers/posts_controller.rb | 6 +-
app/models/post.rb | 2 +
db/migrate/20260419_add_published_at_to_posts.rb | 5 +++
db/schema.rb | 3 +-
test/controllers/posts_controller_test.rb | 22 ++++-
test/models/post_test.rb | 18 ++++
6 files changed, 50 insertions(+), 6 deletions(-)
Fifty lines across six files. I `git diff`ed each file.
Three things jumped out:
-
Time.current— good, that’s the Rails time-zone-aware version, not plainTime.now. -
The scope used a string fragment (
"published_at ⇐ ?", Time.current). I prefer hash conditions when possible, so I asked Claude to change it towhere(published_at: ..Time.current). It did, tests still passed, I re-diffed. -
The controller test logged in a fake user with
sign_in(users(:one)). But I hadn’t added authentication yet — there is nosign_inhelper in this app. I asked Claude to stub it with asession[:user_id]-based helper. It produced a helper, the tests passed, and I re-diffed.
Issue #3 is the classic agent failure mode: it pattern-
matched "testing authenticated access" onto the Devise-style
sign_in helper, which this app does not have. Running the
tests caught it — they failed with
NoMethodError: undefined method 'sign_in'. If I had only
trusted Claude’s "All 8 tests pass" claim I would have missed
it, because the first time Claude ran the tests it wrote
the helper inline and that worked. The second time, after I
rejected the helper, the tests told the truth.
Committing
One feature, one commit. The agent drafted the message; I edited it down:
$ git commit -m "Add published_at to Post with public/draft split"
The Takeaway
Fifteen minutes for something I would have spent forty-five minutes on by hand — and more importantly, the quality is higher than if I had rushed it myself, because the agent wrote tests I would have skipped.
The only reason that math works is that I did the verification. Without the verification, the bug in the controller test would have sat in main for a week and blown up in CI on a Friday.
Prompt Patterns That Work With Rails
A handful of Rails-shaped tasks map very cleanly to prompts. Most of these also appear as sidebars in the relevant chapter later in the book.
Scaffold, Then Modify
bin/rails generate scaffold Product name price:decimal{7,2}, add avalidates :name, presence: true, uniqueness: true, and a model test that proves both conditions.
Agents are excellent at "run one of the known generators, then make small edits". The generator is deterministic; the agent only has to do the small part.
Verify: bin/rails test test/models/product_test.rb.
Migration + Model + Fixtures + Test
Add a
statusstring column to Order with default "pending" and an index. Define anenum :status, %w[pending paid refunded]. Updatetest/fixtures/orders.ymlso each fixture has an explicit status. Write one model test per enum transition.
This is a single feature that touches four files and will go wrong in four different ways if you split it.
Verify: run the migration, run the test, check db/schema.rb.
Refactor a Fat Controller
CheckoutController#createis 80 lines. Extract the ordering logic into a plain Ruby object atapp/services/place_order.rb. Preserve all existing controller tests. Add unit tests for the new object.
The important word is "preserve" — you are instructing the agent to keep the test suite as a contract it may not break.
Verify: test suite still green; controller noticeably smaller;
new file is independently testable (no Rails.application
references).
Extract Hard-Coded Strings Into I18n
Grep all
.html.erbfiles inapp/views/for user-visible English strings. Replace each one witht("<key>")and add the key toconfig/locales/en.yml. Keep keys descriptive (posts.index.empty_state, notmsg1).
An LLM is perfect at this — the work is mechanical but tedious.
Verify: grep -r '[A-Z][a-z][a-z]' app/views/ should come up
almost empty; the app still renders.
Plan a Rails Minor Upgrade
Read the Rails 8.1 upgrade notes at https://guides.rubyonrails.org/upgrading_ruby_on_rails.html#upgrading-from-rails-8-0-to-rails-8-1, run
bin/rails app:update, review everyconfig/*/.rbchange it proposes, and write me a bulleted list of what’s changing, grouped by "definitely accept", "accept with edits", and "reject".
You still review every change. The agent saves you the work of reading the notes end-to-end.
Verify: bin/rails test, bin/rails test:system, and boot
the app.
Common Failure Modes
Patterns I’ve seen over and over. Learn to smell them.
Inventing Methods That Don’t Exist
The agent writes Post.order_by_published_at_desc (not a
real method) or references a gem API that was removed two
versions ago.
Smell: the test fails with NoMethodError on an API you
don’t recognise.
Cure: read the actual Rails guide or the ri output for
the method. Push back on the agent with the correct name.
Re-Introducing Deprecated Patterns
You say "add a form" and the agent reaches for form_for or
params.require(:foo).permit(…) because its training data
has more Rails 5.2 than Rails 8 code.
Smell: deprecation warnings in the test output.
Cure: put the Rails version in CLAUDE.md explicitly, and
list the modern APIs you prefer.
Over-Engineering
A three-line helper gets wrapped in a service object, a policy class, and a concern.
Smell: the diff is ten times the size of the feature request.
Cure: explicitly say "the simplest thing that could work". Keep asking "why do we need that abstraction?".
Silently Disabling Tests
The agent can’t figure out why a test fails so it adds
skip or deletes the test.
Smell: fewer assertions than before in the test summary; a
new skip or pending appears in the diff.
Cure: diff every commit. "skip" and "pending" should never appear without a comment explaining why.
Committing Secrets
The agent helpfully pastes your RAILS_MASTER_KEY into a
README.md while writing setup instructions.
Smell: git diff shows a long random-looking string
anywhere other than `.gitignore`d files.
Cure: .gitignore from the start, git-secrets or
similar scanners in CI, and review every git add.
When Not To Use An Agent
Short honest list.
-
When you’re learning Rails for the first time. Do the Ruby Basics and First Steps chapters by hand. The fastest way to a long career is to understand what the agent is doing.
-
When the task is five seconds of typing. Overhead of explaining exceeds the work. Just type it.
-
For a one-off exploration in a throwaway console. You already have
bin/rails console. Use it. -
When the stakes are high and the domain is subtle. Touching money, auth, or anything regulatory? An agent is still fine as a pair, but the human reviewer must understand the domain, not just the code.
Further Reading
-
The Rails guides — https://guides.rubyonrails.org — remain the canonical source of truth that every agent was trained on. Reading them once pays back forever.
-
Anthropic’s prompt engineering guide — https://docs.anthropic.com/en/docs/build-with-claude/prompt-engineering/overview. Tool-specific but the principles are transferrable.
-
The conventional-commits spec — https://www.conventionalcommits.org — gives you a habit for writing commit messages that future agents (and future-you) will thank you for.
-
ClaudeOnRails by Obie Fernandez. A Rails gem that provisions a pre-wired
CLAUDE.md, a swarm of specialised agents, and optional Rails MCP Server integration. Worth trying once you are fluent in the hand-rolled workflow this chapter teaches.