LaunchKit

Claude Code in a Rails codebase

September 24, 2026

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 because Invoice includes 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.md files, at .claude/skills/<name>/SKILL.md for a project or ~/.claude/skills/<name>/SKILL.md for you personally. The skill's description is 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>.md and 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/ and hooks/hooks.json at its root and a manifest at .claude-plugin/plugin.json, and its skills are namespaced as /plugin-name:skill-name so 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.

The same Rails repository checked twice at launch. On the left CLAUDE.md, .claude/CLAUDE.md and CLAUDE.local.md are all absent, so AGENTS.md is read. On the right nothing changed except a gitignored CLAUDE.local.md, which is found on the third check, and AGENTS.md is never read, with no warning and only for the person who added the file.

Concretely, for the LaunchKit boilerplate, the 66 line AGENTS.md spends its budget on: that controllers stay thin and multi-field writes become a form object under app/forms/ or a service under app/services/, that every user-facing string goes through config/locales/en.yml and never into an .erb, that optional modules are gated on Feature.enabled?(:ai), and that secrets are read through AppConfig rather than ENV. 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.md only when there is no CLAUDE.md, .claude/CLAUDE.md or CLAUDE.local.md in your working directory or above it. CLAUDE.local.md counts, 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 setting claude-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 a paths field on a rule so testing.md loads when Claude opens something under spec/ 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.

---
name: new-service
description: Add a service object under app/services with its spec and locale keys. Use when asked for multi-step business logic.
paths: "app/services/**,spec/services/**"
allowed-tools: Read, Grep, Glob
---

paths limits when the skill is offered, allowed-tools pre-approves tools for that turn without a prompt, and disable-model-invocation: true makes a skill yours to trigger and invisible to the model's own judgement. context: fork runs the skill in an isolated subagent context instead of the main one.

What does not hold up is treating the description as 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.

---
name: suite-runner
description: Runs the full RSpec suite and reports which files failed and the first assertion failure in each.
tools: Read, Grep, Glob, Bash
model: sonnet
---

Run `bundle exec rspec`. Report every failing file, the line number, and the expected and actual
values. Do not edit any file.

The tools line is an allowlist, so the definition above is structurally incapable of writing to the repository. disallowedTools is the denylist form and is applied first when both are present. isolation: worktree runs 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.json for rules you commit, .claude/settings.local.json at the repository root for your own, and ~/.claude/settings.json for 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, cd and read-only forms of git. So the list you write is about the commands that change something.

{
  "permissions": {
    "allow": [
      "Bash(bundle exec rspec *)",
      "Bash(bin/rubocop *)",
      "Bash(bin/rails db:migrate)",
      "Bash(bin/rails runner *)"
    ],
    "deny": [
      "Bash(bin/rails db:drop *)",
      "Bash(git push *)"
    ]
  }
}

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 ask rule prompts even when a more specific allow also matches. And a bare tool name in deny, written as Bash or Bash(*), 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, because runner takes arbitrary Ruby. Allowing it is allowing everything the application can do, including User.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 is bundle exec rspec. Claude Code strips a fixed set of wrappers before matching: timeout, time, nice, nohup, stdbuf, the builtins command and builtin, zsh's noglob, and bare xargs. bundle is not on that list and neither is docker, so the rule has to contain the wrapper: Bash(bundle exec rspec *), and separately Bash(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:

Rule Matches Does not match
Bash(bundle exec rspec *) bundle exec rspec, bundle exec rspec spec/models bin/rspec spec/models
Bash(bin/rails db:migrate) that exact string bin/rails db:migrate VERSION=20260101000000
Bash(bin/rails *) every rails task, including db:drop bundle exec rails db:migrate
Bash(rails*) rails, railsdoctor bin/rails

A trailing * preceded by a space also matches the bare command, so Bash(bundle exec rspec *) covers bundle exec rspec with 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 push needs 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 is NODE_ENV. Whether RAILS_ENV=test bundle exec rspec matches 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:42 gives 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_MS defaults to 120000, two minutes, and BASH_MAX_TIMEOUT_MS to 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.rb is green, bundle exec rspec is 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/ci step 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 PreToolUse hook blocks an action regardless of what the model decided, and a PostToolUse hook matching Write|Edit is 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.

Keep reading

← All of Rails and agents