Skip to content

Ruby on Rails Recipes

Ruby on Rails recipes for AI coding agents are prompts and project settings that make Claude Code, Codex and Cursor ship trustworthy Rails 8.1 changes: generators instead of hand-written boilerplate, RSpec request specs written before the code, bin/ci as the definition of done, migrations checked for reversibility and lock safety, and N+1 queries that fail a spec.

This page is for Rails developers who already let an agent write most of a feature, and for tech leads who want the same gates enforced in CI for the whole team. The situation it fixes: the agent’s pull request is green, but it hand-wrote a migration with a timestamp from last year, edited db/schema.rb directly, rendered a list with order.customer.name in the loop, and “fixed” a failing spec by changing the expected value. The recipes below make the agent run into Rails’ conventions, generators and CI runner on every turn, so you review evidence instead of diffs.

  • A config/ci.rb that turns bin/ci into the gate the agent must pass: RuboCop, Brakeman, RSpec and a migration-reversibility step.
  • An AGENTS.md for a Rails app, wired into Claude Code, Codex and Cursor.
  • A generator-first prompt that stops the agent from hand-writing migrations, models and specs.
  • A spec-first loop for a Rails endpoint, from acceptance criteria to failing request specs to the implementation.
  • A Claude Code Stop hook that refuses “done” until RuboCop and RSpec pass, and a CI job that is the mechanical gate for Codex and Cursor.
  • N+1 detection that fails specs, with the choice between Bullet, Prosopite and strict_loading tested on Rails 8.1.4.
  • A migration recipe with strong_migrations, a schema-drift check and a human sign-off on destructive changes.
  • Mutation testing with mutant to show whether the agent’s specs would catch a bug.

Which Ruby, Rails and gem versions do these recipes assume?

Section titled “Which Ruby, Rails and gem versions do these recipes assume?”

Agents mix APIs from Rails 5, 6, 7 and 8 because their training data does. Tell the agent the versions and pin them (.ruby-version, Gemfile.lock) so it cannot drift. These were current on 2026-09-26:

ComponentVersionSource (checked 2026-09-26)
Ruby4.0.7 (2026-09-15)ruby-lang.org releases
Rails8.1.4 (2026-09-24); requires Ruby 3.2 or laterrubygems.org
rspec-rails / factory_bot_rails8.0.4 / 6.5.1rubygems.org
rubocop / rubocop-rails / rubocop-rails-omakase1.91.0 / 2.38.0 / 1.1.0rubygems.org
brakeman8.0.6rubygems.org
bullet / prosopite8.2.0 / 2.2.0rubygems.org
strong_migrations2.8.0rubygems.org
mutant / mutant-rspec0.17.0rubygems.org
ruby-lsp0.26.11rubygems.org

The Rails, RSpec, Bullet, Prosopite, strict_loading and mutant results come from a fresh Rails 8.1.4 app (Ruby 3.3.6, SQLite, rspec-rails 8.0.4) on 2026-09-26. Rails 8.1.4 requires Ruby 3.2.0 or later, so it installs on Ruby 4.0, but the recipes were not rerun on 4.0.7. The PostgreSQL workflow and the strong_migrations steps follow the gems’ documentation.

Put the gates in files the agent cannot forget. Rails 8.1 already generates most of them: rails new writes .rubocop.yml (inheriting rubocop-rails-omakase), bin/brakeman, bin/bundler-audit and a local CI runner, bin/ci, that reads its steps from config/ci.rb and exits non-zero when any step fails. Add the two steps an agent most often skips:

config/ci.rb
# Run using bin/ci
CI.run do
step "Setup", "bin/setup --skip-server"
step "Style: Ruby", "bin/rubocop"
step "Security: Gem audit", "bin/bundler-audit"
step "Security: Brakeman code analysis", "bin/brakeman --quiet --no-pager --exit-on-warn --exit-on-error"
step "Tests: RSpec", "bin/rspec"
step "Migrations: reversible", "bin/rails db:migrate:redo STEP=1"
end

The last step rolls back the newest migration and applies it again. On a test app, an agent-written remove_column :orders, :total_cents without a type raised ActiveRecord::IrreversibleMigration in that step and bin/ci exited with status 1. Create the bin/rspec binstub with bundle binstubs rspec-core.

Rails defaults to Minitest. If you use RSpec, add rspec-rails to the :development, :test group, run bin/rails generate rspec:install, and tell the generators which specs you want, so bin/rails generate stops producing view and helper specs nobody maintains:

config/application.rb
config.generators do |g|
g.test_framework :rspec, view_specs: false, helper_specs: false, routing_specs: false
g.helper false
end

With this block, bin/rails generate scaffold Invoice number:string order:references creates only a model spec and a request spec. Uncomment config.example_status_persistence_file_path = "spec/examples.txt" in spec/spec_helper.rb (the generated file ships it inside a =begin block). Without it, bin/rspec --only-failures has nothing to read, and the agent re-runs the whole suite on every iteration.

Write the commands the agent must run, in the order it must run them, and the few rules a generic Rails model gets wrong in your codebase. Leave out anything RuboCop or config/ci.rb already enforces.

AGENTS.md
# Shop (Rails 8.1, Ruby 4.0, PostgreSQL, RSpec, Hotwire)
## Commands (run from the repo root)
- One spec file or example: `bin/rspec spec/requests/orders_spec.rb:12`
- Failures from the last run: `bin/rspec --only-failures`
- Lint with safe autocorrect: `bin/rubocop -a`
- Full gate: `bin/ci` (RuboCop, Brakeman, bundler-audit, RSpec, migration redo)
- Preview a generator: add `--pretend` to any `bin/rails generate` command
## Definition of done
`bin/ci` passes. Report the command you ran and its last lines.
## Rules
- Create models, migrations, controllers, jobs and mailers with `bin/rails generate`, never by hand.
- Never edit db/schema.rb or an existing migration. Add a new migration and run `bin/rails db:migrate`.
- Specs are the specification. If a spec fails, fix app/, not spec/. Ask before changing an expectation.
- Request specs, not controller specs. Use FactoryBot factories from spec/factories.
- Any action that renders a collection uses `includes` or `preload`; the N+1 detector fails the spec otherwise.
- Never add `# rubocop:disable`, a Brakeman ignore entry or `safety_assured` without asking.
- Money is integer cents (`*_cents`), never Float.

Each tool loads the same file differently:

Create a CLAUDE.md whose first line imports the shared file, then add any Claude-only lines below it:

CLAUDE.md
@AGENTS.md

Claude Code 2.1.277 and later also read AGENTS.md directly when a project has no CLAUDE.md (first-party sessions; v2.1.281 on Bedrock, Google Cloud, Foundry and LLM gateways; both on the latest release channel on 2026-09-26). The import works on every version and channel.

Shared agent rules explains how to keep one source of truth when a team uses all three tools.

How do you make the agent use Rails generators instead of writing files by hand?

Section titled “How do you make the agent use Rails generators instead of writing files by hand?”

A hand-written migration can break Rails in ways specs do not show: a timestamp that sorts before migrations already in production, a Migration[7.0] superclass copied from training data, or a model without its spec. Generators produce the file names, the migration version and the spec skeletons your configuration asks for. The --pretend flag shows what a generator would create without writing anything, which makes it a cheap plan step:

Terminal window
# terminal: show the plan, write nothing
bin/rails generate model Refund order:references amount_cents:integer reason:string --pretend

The last line matters: the db/schema.rb diff is the shortest honest summary of what a migration does. Read that, not the Ruby.

How do you run a spec-first loop for a Rails endpoint?

Section titled “How do you run a spec-first loop for a Rails endpoint?”

Specify the behaviour, have the agent write failing request specs, check that they fail for the right reason, and only then let it write the implementation. Request specs go through routing, middleware and the database, so they are the oracle closest to what a user sees.

  1. Write the acceptance criteria. Three to six observable behaviours, each one a sentence a spec can check. Acceptance criteria an agent can verify shows the format.

  2. Ask for failing specs only. Use the first prompt below. Stop the agent once the specs load and fail.

  3. Read the failures, not the code. Each spec should fail on its expectation (expected 201, got 404), not on a missing factory or a load error. A spec that fails for the wrong reason will pass for the wrong reason too.

  4. Ask for the implementation. Use the second prompt. The loop ends when bin/ci passes, not when the agent says it is done.

  5. Check spec strength. Run mutant on the changed methods (see How do you know the agent’s specs would catch a bug?).

Protect the oracle explains why the “do not modify spec/” instruction needs a mechanical backstop as well, which the next section adds.

How do you stop the agent from declaring done before the specs pass?

Section titled “How do you stop the agent from declaring done before the specs pass?”

An instruction in AGENTS.md is advice. A hook or a CI check is a gate. Claude Code gets a local gate from the Stop hook below. Codex and Cursor have hooks too, but the setups below use a prompt and a project rule, so for them the CI job after the tabs remains the gate.

A Stop hook runs when Claude finishes responding. Exit code 2 prevents the stop and shows the hook’s stderr to Claude, which then keeps working. Run the fast part of the gate here and leave the full bin/ci (which also audits gems over the network) to the end of the task and to CI:

.claude/hooks/rails-gate.sh
#!/usr/bin/env bash
# Refuse "done" until RuboCop and RSpec pass.
input=$(cat)
# Already continuing because of a stop hook: stop and report instead of looping.
[ "$(echo "$input" | jq -r '.stop_hook_active')" = "true" ] && exit 0
cd "$CLAUDE_PROJECT_DIR" || exit 0
# No changes to code, specs or schema since the branch left main: nothing to check.
base=$(git merge-base HEAD origin/main 2>/dev/null || echo HEAD)
[ -z "$(git status --porcelain -- app spec db config lib)" ] &&
git diff --quiet "$base" -- app spec db config lib && exit 0
if ! out=$(bin/rubocop 2>&1); then
echo "RuboCop failed. Fix the code; run bin/rubocop -a for safe fixes; never add rubocop:disable:" >&2
echo "$out" | grep -E "^[^ ].+:[0-9]+:[0-9]+: " | head -30 >&2
exit 2
fi
if ! out=$(bin/rspec 2>&1); then
echo "RSpec failed. Fix app/, never spec/. Re-run with bin/rspec --only-failures:" >&2
echo "$out" | tail -40 >&2
exit 2
fi
exit 0

The script needs jq. Because it exits 0 when stop_hook_active is true, a failing gate forces one retry, and then Claude stops and reports instead of looping on a spec that cannot pass. The guard skips the run when nothing under app, spec, db, config or lib differs from origin/main, whether the change is uncommitted or already committed; change origin/main if your default branch has another name. Hooks automation explains how to allow more than one retry and covers the other events.

Register it in the project settings and make the script executable (chmod +x):

.claude/settings.json
{
"hooks": {
"Stop": [
{
"hooks": [
{ "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/rails-gate.sh", "timeout": 600 }
]
}
]
}
}

On a large suite, replace bin/rspec with the spec files that match the changed files, and keep the full run in CI.

Whatever runs locally, CI is the gate that decides. Run the same checks there with a read-only token and a throwaway PostgreSQL service:

.github/workflows/rails-gates.yml
name: rails-gates
on:
pull_request:
types: [opened, synchronize, reopened, labeled, unlabeled]
permissions:
contents: read
jobs:
gates:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:17
env:
POSTGRES_HOST_AUTH_METHOD: trust # disposable CI database, no credential
ports: ["5432:5432"]
options: --health-cmd="pg_isready" --health-interval=10s --health-timeout=5s --health-retries=3
env:
RAILS_ENV: test
DATABASE_URL: postgres://postgres@localhost:5432/shop_test
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
fetch-depth: 2 # the PR merge commit and its base parent
- name: Existing specs unchanged
if: ${{ !contains(github.event.pull_request.labels.*.name, 'spec-change-approved') }}
run: |
changed=$(git diff --name-only --diff-filter=MDR HEAD^1 HEAD -- spec/)
if [ -n "$changed" ]; then
echo "Existing specs modified, moved or deleted; a reviewer must add the spec-change-approved label:"
echo "$changed"
exit 1
fi
- uses: ruby/setup-ruby@v1
with:
bundler-cache: true
- run: bin/rubocop
- run: bin/bundler-audit
- run: bin/brakeman --quiet --no-pager --exit-on-warn --exit-on-error
- run: bin/rails db:create db:migrate
- run: git diff --exit-code db/schema.rb # migrations and schema.rb agree
- run: bin/rails db:migrate:redo STEP=1 # newest migration is reversible
- run: bin/rspec

The “Existing specs unchanged” step fails when the pull request modifies, renames or deletes an existing file under spec/, so the agent cannot quietly move an expected value or drop a failing spec; new spec files pass. After reviewing the spec change, a person adds the spec-change-approved label, which starts a new run that skips the step (re-running the old job does not work, because it reuses the original event and does not see the label). The git diff step fails when the agent edited db/schema.rb by hand or committed a migration without the schema it produces.

How do you catch N+1 queries in agent-written Rails code?

Section titled “How do you catch N+1 queries in agent-written Rails code?”

Agents readily write the N+1 loop, because @orders.each { |o| o.customer.name } is correct Ruby and every spec with one record passes. The fix is a detector that turns the pattern into a failing spec. Each row below was tested on Rails 8.1.4 on 2026-09-26:

DetectorSetupWhat it caught in our testChoose it when
Bullet 8.2.0Bullet.enable, Bullet.raise = true in config/environments/test.rbThe order.customer loop in a request spec, on SQLite, with the fix in the message: Add to your query: .includes([:customer])Any database, including Rails 8’s default SQLite. Request specs work through its Rack middleware; model specs need Bullet.start_request and Bullet.end_request around each example
Prosopite 2.2.0Prosopite.raise = true, Prosopite.scan and Prosopite.finish around each example, plus the pg_query gem unless you use MySQL or MariaDBThe same loop on SQLite, but as PgQuery::ParseError: syntax error at or near "LIMIT" instead of an N+1 reportPostgreSQL or MySQL. It flags repeated identical queries, so it also catches N+1s that do not go through an association
strict_loading (built in)Order.strict_loading on a relation, or self.strict_loading_by_default = true in a modelThe loop, as ActiveRecord::StrictLoadingViolationError, when set on the relation. With strict_loading_by_default = true and strict_loading_mode = :n_plus_one_only, it raised for none of the loops we triedNew models and hot queries where every lazy load is a bug, including single-record ones

For most teams, Bullet in the test environment is the first gate, with strict_loading on the relations behind your busiest pages:

config/environments/test.rb
Rails.application.configure do
config.after_initialize do
Bullet.enable = true
Bullet.bullet_logger = true
Bullet.raise = true # an N+1 in any request spec fails that spec
end
# ...
end

The detector only fires when a spec loads more than one record, so add a collection example to every index action. Criterion 5 in the refund prompt above is that example.

For the database side of the same problem, including EXPLAIN plans and indexes, see SQL patterns and ORM patterns.

How do you handle Rails migrations with an agent?

Section titled “How do you handle Rails migrations with an agent?”

Specs alone cannot vouch for a migration: the agent can write a migration that applies cleanly to an empty test database and still locks a large table or breaks the running app during deploy. The classic case: the agent renames a column with rename_column, the specs pass, and the app servers still running the old code raise errors on every query that uses the old name.

strong_migrations turns those cases into an error at bin/rails db:migrate time, with the safe alternative in the message. It supports PostgreSQL, MySQL and MariaDB (not SQLite). Install it with bundle add strong_migrations and bin/rails generate strong_migrations:install. Removing a column, for example, fails with instructions to add the column to self.ignored_columns, deploy, and only then remove it inside safety_assured { ... }.

Split the rest of the work between gates and a person:

CheckHowWho decides
Unsafe operationstrong_migrations raises on the migrationCI, and the agent reads the message
Schema and migrations agreebin/rails db:migrate on a fresh database, then git diff --exit-code db/schema.rbCI
The newest migration is reversiblebin/rails db:migrate:redo STEP=1CI (bin/ci and the workflow above)
The change is what you expectThe db/schema.rb diff attached to the pull requestA human reads the schema diff, not the Ruby
safety_assured, destructive or data-backfill migrationsCODEOWNERS on db/migrate/A human

For the wider rules on expand-and-contract changes and rollbacks, see database migration patterns.

How do you know the agent’s specs would catch a bug?

Section titled “How do you know the agent’s specs would catch a bug?”

A green run does not tell you the specs would fail if the code were wrong, and specs written by the same agent that wrote the code are the ones most likely to share its blind spots. Mutation testing answers the question: mutant changes the code under test in small ways and reports every change the specs did not notice.

On the test app, one spec for Order#large? (total_cents >= 10_000) passed, and mutant reported 7 surviving mutations out of 18, 61.11% coverage. The first survivor was the boundary: total_cents > 10000. No spec checked an order of exactly 10,000 cents.

Terminal window
# terminal: mutation-test only the subjects changed since main
bundle exec mutant run --integration rspec --usage opensource \
--require ./config/environment --since main -- 'Order*'

Add mutant-rspec to the :development, :test group with require: false. mutant is free for open-source projects (--usage opensource); private, commercial code needs a paid subscription and --usage commercial. Run it on the changed subjects rather than the whole app, because every mutation re-runs the selected specs. Oracle strength explains how to turn survivors into missing examples.

How do you give the agent Rails-aware code navigation?

Section titled “How do you give the agent Rails-aware code navigation?”

Text search finds def total but not every caller of an association, a scope or a route helper. Two additions help most in Rails: a language server that understands Ruby, and an MCP server that reads routes, schema and model relationships.

Ruby LSP. Shopify’s language server (ruby-lsp gem, 0.26.11) includes its Rails add-on automatically when it detects a Rails app. In Claude Code, the ruby-lsp plugin in the official marketplace registers it; the plugin does not bundle the binary. LSP plugins add no always-on context, because the server runs out of process.

Rails MCP Server. The rails-mcp-server gem (2.0.0) exposes three tools, switch_project, search_tools and execute_tool, and behind them analyzers such as get_routes, get_schema and analyze_models. With --single-project it serves the current directory without a projects.yml. Popularity: 261,700 total downloads on rubygems.org (read 2026-09-26). It is a community project, so read its source before you give it a production database. Its always-on tool-definition cost is small; the analyzers load only when called through execute_tool.

Terminal window
# terminal
gem install ruby-lsp
claude plugin install ruby-lsp@claude-plugins-official
gem install rails-mcp-server
claude mcp add rails -- rails-mcp-server --single-project

Without them, the agent’s grep for has_many :refunds misses a scope defined in a concern. With them, one prompt maps the blast radius first:

Code intelligence MCP servers compares language-server and index-based options for larger codebases.