Claude Code in a Rails codebase
September 24, 2026
Keep reading
- Rails vs Rust in 2026, after HEY moved its backend
- Is Rails token efficient code? Counting what an agent has to read
- Testing what an agent wrote
- Reviewing Rails an agent wrote
- Pencils down: what DHH actually said at Rails World 2026
- Cursor rules for a Rails codebase
- Convention over configuration is a context argument now
- AGENTS.md for a Rails app
Claude Code will write an ActiveRecord model, its migration and its spec in a single turn. The failure mode is not the code it writes. The failure mode is the agent running
bundle exec rspec spec/models/invoice_spec.rb, seeing green, and stopping, while four other files are red becauseInvoiceincludes a concern the agent never opened.That is a configuration problem rather than a model problem. What DHH actually said at Rails World is the argument that the implementation is no longer the scarce part, and in the same week he described moving HEY to native apps on every platform and a Rust backend, which is not a story about Rails winning anything. What is left on your side either way is telling the agent what this codebase is and what it is allowed to run. In Claude Code that is four mechanisms, and the useful thing to know about them first is that they enter the context window at four different moments.
The four mechanisms, and when each one enters the context window
CLAUDE.md files load at launch, from the working directory and every directory above it, concatenated root first so the file closest to where you started is read last. Files in subdirectories are held back and included when Claude reads a file in that subdirectory. The documentation's own size guidance is to target under 200 lines per file, on the grounds that longer files both consume context and reduce adherence.
Skills are
SKILL.mdfiles, at.claude/skills/<name>/SKILL.mdfor a project or~/.claude/skills/<name>/SKILL.mdfor you personally. The skill'sdescriptionis in context always so the model knows the skill exists, and the body loads only when the skill is invoked, by you as/<directory-name>or by the model matching the description. Once loaded it stays for the rest of the session.Subagents live in
.claude/agents/<name>.mdand get a fresh context window: your conversation history never reaches one, and what comes back to you is a summary.Plugins are the distribution wrapper around the first three. A plugin is a directory with
skills/,agents/andhooks/hooks.jsonat its root and a manifest at.claude-plugin/plugin.json, and its skills are namespaced as/plugin-name:skill-nameso two plugins can both ship a/review. Nothing a plugin does is unavailable to a plain.claude/directory. What it buys is a versioned thing your team installs instead of copies.What belongs in CLAUDE.md, and the AGENTS.md trap
A Rails application is unusually cheap to describe, because most of it is convention and the agent already knows the convention. The file earns its context on the places your app departs from default Rails, and conventions are the context is the longer argument for why that is the whole job.
Concretely, for the LaunchKit boilerplate, the 66 line
AGENTS.mdspends its budget on: that controllers stay thin and multi-field writes become a form object underapp/forms/or a service underapp/services/, that every user-facing string goes throughconfig/locales/en.ymland never into an.erb, that optional modules are gated onFeature.enabled?(:ai), and that secrets are read throughAppConfigrather thanENV. Four rules an agent cannot infer from the file tree, each one naming the directory or the method that proves it.The trap is which file gets read. Claude Code reads
AGENTS.mdonly when there is noCLAUDE.md,.claude/CLAUDE.mdorCLAUDE.local.mdin your working directory or above it.CLAUDE.local.mdcounts, which means the gitignored file you added for your own sandbox URLs stops Claude reading the project's AGENTS.md, silently, for you only. The fix is the Project instructions settingclaude-md-and-agents-md, which loads both. One file for every agent covers that split properly.For a large application,
.claude/rules/is the pressure valve: one markdown file per topic, and apathsfield on a rule sotesting.mdloads when Claude opens something underspec/rather than on every session.Skills are for the procedure you repeat, not the facts you always need
The split between CLAUDE.md and a skill is a context budget decision. Facts needed on every turn go in CLAUDE.md and cost you their full length every session. A procedure needed on one turn in twenty goes in a skill and costs you one line until it fires.
In a Rails repository the procedures worth writing down are the ones with a checklist that nobody remembers in full: adding a service object with its spec and its locale keys, cutting a migration that has to be backfilled in two deploys, adding a feature flag and remembering to gate the route, the nav and the view.
pathslimits when the skill is offered,allowed-toolspre-approves tools for that turn without a prompt, anddisable-model-invocation: truemakes a skill yours to trigger and invisible to the model's own judgement.context: forkruns the skill in an isolated subagent context instead of the main one.What does not hold up is treating the
descriptionas a switch. A skill fires on the model reading that line and deciding it matches, which is a judgement, not a route. Two skills with overlapping descriptions will not reliably pick the same one twice, and the answer is to write descriptions that name the trigger in the user's words rather than to add a third skill explaining when to use the other two.A subagent is a second context window, not a second opinion
The reason to define a subagent in a Rails repository is almost always volume. A failing RSpec run is a few thousand lines of backtrace, most of it framework frames, and pouring that into the main conversation costs you the context you wanted for the fix.
The
toolsline is an allowlist, so the definition above is structurally incapable of writing to the repository.disallowedToolsis the denylist form and is applied first when both are present.isolation: worktreeruns the agent in a temporary git worktree with its own copy of the repository, which is the honest answer to "what if it edits something while I am editing".The cost is stated in the mechanism: a subagent returns a summary. You did not see the run, you saw its report of the run, and a report saying the suite is green is exactly as trustworthy as the agent that wrote it. That is the same problem as reviewing agent written Rails, arriving one level further away from the code. For anything where the output is the evidence, keep it in the main conversation and pay the tokens.
Which commands to allow in a Rails repository
Permission rules live in
.claude/settings.jsonfor rules you commit,.claude/settings.local.jsonat the repository root for your own, and~/.claude/settings.jsonfor every project on your machine. Claude Code already treats a built-in set as read only and never prompts for it:ls,cat,grep,find,head,tail,wc,diff,stat,cdand read-only forms ofgit. So the list you write is about the commands that change something.Two properties of that file decide whether it does what you meant. A deny rule beats an allow rule unconditionally, so a narrow allow cannot carve an exception out of a broad deny, and a matching
askrule prompts even when a more specific allow also matches. And a bare tool name indeny, written asBashorBash(*), removes the tool from the model's context entirely rather than blocking calls to it.Bash(bin/rails runner *)in that list is not a safe rule and is in it on purpose, becauserunnertakes arbitrary Ruby. Allowing it is allowing everything the application can do, includingUser.destroy_all. Either you accept that for a development database you can rebuild, or you leave it out and take the prompt. What you should not do is add it because a prompt interrupted you once.The allow rule that looks right and matches nothing
Bash(rspec *)is the rule most people write first, and in a Rails project it matches nothing anybody types, because what gets typed isbundle exec rspec. Claude Code strips a fixed set of wrappers before matching:timeout,time,nice,nohup,stdbuf, the builtinscommandandbuiltin, zsh'snoglob, and barexargs.bundleis not on that list and neither isdocker, so the rule has to contain the wrapper:Bash(bundle exec rspec *), and separatelyBash(docker compose exec web bundle exec rspec *)if your development stack is the compose file.The rest of the matching rules are worth a slow read, because each one has a Rails shaped way to get it wrong:
Bash(bundle exec rspec *)bundle exec rspec,bundle exec rspec spec/modelsbin/rspec spec/modelsBash(bin/rails db:migrate)bin/rails db:migrate VERSION=20260101000000Bash(bin/rails *)db:dropbundle exec rails db:migrateBash(rails*)rails,railsdoctorbin/railsA trailing
*preceded by a space also matches the bare command, soBash(bundle exec rspec *)coversbundle exec rspecwith no arguments. The:*suffix is an equivalent spelling of that trailing wildcard, and is only recognised at the end.Bash(command:rspec *)is ignored outright with a startup warning, because a rule scoped to a tool's primary content field would be bypassable by a compound command.Two more that bite. A compound command is matched per subcommand, so
bundle exec rspec && git pushneeds both halves allowed. And an allow rule does not match past a leading environment assignment except for a small built-in set of known-safe variables, of which the documented example isNODE_ENV. WhetherRAILS_ENV=test bundle exec rspecmatches your rule is therefore something to check with one real invocation rather than assume.The loop that works: run the suite, read the failure, fix one thing
The loop that produces working Rails code has three steps and no cleverness in it. The agent runs the suite. The agent reads the first failure. The agent changes one thing and runs the suite again.
What makes that loop work is not the prompt, it is that a failing RSpec example prints the file, the line and the diff between expected and actual, which is a complete instruction for the next edit. A Rails suite is an unusually good agent environment for exactly this reason: the feedback is specific, it is textual, and it is cheap to re-run. Pointing at
spec/models/invoice_spec.rb:42gives the agent a target it can verify it hit.The version that does not work is the agent writing the implementation and then writing a spec that passes against it. A spec written after the fact, by the same process that wrote the bug, asserts the behaviour that exists. LaunchKit's own AGENTS.md says it in one line, "for a bug, write the failing spec first, then fix it", and that instruction is worth more to an agent than to a person, because a person feels the awkwardness of a test they know will pass. Testing what an agent wrote is where that goes next.
An agent that cannot run your suite is guessing
Take the suite away and the agent does not stop. Agents do not report insufficient evidence, they produce the most plausible next edit, and without a suite the most plausible edit is whatever the code around it looks like. That is guessing with good syntax.
A suite the agent cannot finish running is the same problem with a longer fuse.
BASH_DEFAULT_TIMEOUT_MSdefaults to 120000, two minutes, andBASH_MAX_TIMEOUT_MSto 600000, so a suite that takes four minutes is killed at two unless the call carries a longer timeout, and no timeout above ten minutes is available at all. A killed run produces no failure list, only a truncated one.What happens next is a behaviour change rather than an error. The agent narrows: it runs the one spec file it just edited, that file is green, and the work is declared finished. On a Rails codebase that is precisely the wrong narrowing, because the things an agent most often changes are the things with the widest blast radius. A concern, a callback, an initializer, a shared factory, a locale key that a request spec asserts on.
bundle exec rspec spec/models/invoice_spec.rbis green,bundle exec rspecis not, and nobody looked.The practical fix is unglamorous and belongs to you rather than to the agent. Make a subset that is fast, honest and named, so the loop can run on something bigger than one file: a rake task or a
bin/cistep the agent is allowed to call by name, wired to the directories the change actually touches. Then run the whole suite before you review, not while the agent works. Token efficient Rails covers the other half of that budget, which is what the agent has to read before it can run anything.What this page does not cover
Not hooks, which are the enforcement layer rather than the instruction layer. A
PreToolUsehook blocks an action regardless of what the model decided, and aPostToolUsehook matchingWrite|Editis how you get rubocop run on every file the agent touches. Both are configuration this page does not have room for, and both are the right answer when a CLAUDE.md instruction keeps getting ignored, because CLAUDE.md is context and a hook is a gate.Not MCP servers, and not the comparison with other tools. Cursor rules for Rails is the sister page for that side, and the mechanisms are close enough that the interesting differences are not the file formats.
Not the question of whether to do any of this. When Rails is still the answer takes that one, and it deserves a harder look than a paragraph here, given that the person who wrote Rails spent the same week explaining why HEY's next backend is not written in it.