Rails Hyperdrive
Live introspection and stack-matched knowledge for AI coding agents, straight from your Rails app.
Rails Hyperdrive is a development-only Rails engine for working on Rails apps with AI coding agents. It gives the agent two things it can't get from source alone: live answers from the booted app, and guidance specific to the gems and versions in the bundle.
-
Live introspection. The engine mounts an MCP (Model Context Protocol) server at
http://localhost:3000/_hyperdrive/mcpwith 8 tools that answer from the running app itself: eval Ruby, query the DB (read-only), tail logs, list models and routes, jump to source, look up docs, snapshot the stack. The agent asks the router instead of greppingroutes.rb, and reads the live schema instead of replaying migrations. -
Stack-specific knowledge.
bin/rails hyperdrive:initdiscovers skills, guidelines, agents, and commands shipped by companion gems and installs only the ones matching your Gemfile: guidance targeting Sidekiq, for example, lands only if your app bundles Sidekiq, at a version the guidance covers.
rails-hyperdrive is the mechanism; companion gems are the content. The gem itself ships no skills or guidelines, only the contract and the discovery/install engine. Content reaches your app three ways:
-
Native support: the library gem itself ships a top-level
skills/directory in the skills.sh layout and opts in as a hyperdrive companion. The preferred route when the maintainer is on board: one gem, one source of truth. - Adopted skill repos: an existing skills.sh skill repo packaged as a gem, content untouched, gating declared on the side.
- Dedicated companion gems: third-party guidance for a library that ships none itself.
On the last two routes, name the gem rails-hyperdrive-<name>: the prefix tells a Gemfile reader exactly what the gem is, though naming plays no part in discovery.
Rails Hyperdrive is built by Evil Martians, an American design and engineering consultancy for developer tools, AI, and cybersecurity startups.
Quick start
# 1. Add the dev gem
$ bundle add rails-hyperdrive --group=development
# 2. (Optional) Add a companion gem for your stack
$ bundle add rails-hyperdrive-sidekiq --group=development
# 3. Run the generator
$ bin/rails hyperdrive:init
create .mcp.json
append .gitignore
append Gemfile
insert config/routes.rb
create .hyperdrive/config.yml
create .claude/hyperdrive/guidelines/jobs-sidekiq.md
create .claude/skills/sidekiq-idempotency/SKILL.md
create .claude/hyperdrive/index.md
create CLAUDE.md
create .hyperdrive/lock.yml
eager 1 guideline(s), ~240 tokens always in context
done hyperdrive initialized
Mount: /_hyperdrive (in config/routes.rb)
Server: 8 MCP tools at http://localhost:3000/_hyperdrive/mcp
Installed 1 skill, 1 guideline, 0 agents, 0 commands
rails-hyperdrive-sidekiq@1.2.0
skill sidekiq-idempotency
guideline jobs-sidekiq
Next steps:
1. bin/rails server
2. Open Claude Code in this directory; it will read .mcp.json
3. Verify the connection: curl -s http://localhost:3000/_hyperdrive/mcp ...
# 4. Start the dev server
$ bin/dev
# 5. Open Claude Code in the project directory
# → Claude Code reads .mcp.json, connects to http://localhost:3000/_hyperdrive/mcp
# → agent has 8 tools, the eager guidelines (via CLAUDE.md), and the lazy skillsThe engine mounts at /_hyperdrive by default; --mount-at /some/path moves both the route and the URL written to .mcp.json. That value is interpolated into config/routes.rb as Ruby source, so it must be a plain path of one or more /-separated segments of letters, digits, _ and -; anything else stops the run before it writes. If config/routes.rb has no Rails.application.routes.draw do block to anchor to, nothing is mounted and the run warns with the line to add by hand. The generated .mcp.json points at http://localhost:3000<mount>/mcp, so if your dev server runs on another port, edit the URL there.
What your agent gets
8 MCP tools
| # | Tool | Purpose |
|---|---|---|
| 1 | run_ruby |
Eval Ruby in the booted Rails process, with timeout + output capture |
| 2 | run_sql |
Read-only SQL via the AR connection (SELECT/WITH/EXPLAIN/SHOW/PRAGMA only, and no PRAGMA assignments; 100 rows max) |
| 3 | tail_logs |
Tail the last N lines of a log under log/ (defaults to log/<env>.log) |
| 4 | list_models |
List Active Record model classes with columns/validations/associations |
| 5 | locate_source |
Resolve Const / Const#method / Const.method / dep:<gem> to a file:line |
| 6 | lookup_doc |
Look up RDoc for a symbol (via ri) |
| 7 | describe_app |
Snapshot: Rails/Ruby/DB versions, direct gem dependencies, installed skills |
| 8 | list_routes |
All routes: HTTP verb, path, controller#action, named route |
Plus two MCP resources: hyperdrive://stack-profile (JSON snapshot of your resolved stack) and hyperdrive://skills/{name} (the markdown body of each installed skill). The skill list is enumerated at server boot and the stack snapshot is memoized per process, so a newly installed skill or a changed bundle reaches these two only after a dev-server restart.
Four kinds of knowledge
Companion gems ship four artifact kinds, tuned for how agents consume context:
-
Skills: lazy. Loaded on demand via Claude Code's native description matcher. Procedural knowledge: "how to write an idempotent Sidekiq job". Installed to
.claude/skills/<name>/SKILL.md, optionally with supporting files (references, examples, workflows) alongside. -
Guidelines: eager. Always in context via a single
@-include fromCLAUDE.md. Declarative facts: "this app uses ActionPolicy, not Pundit". Installed to.claude/hyperdrive/guidelines/<name>.md. -
Agents: Claude Code subagents, invoked by name or by task match. Installed to
.claude/agents/<name>.md. -
Commands: Claude Code slash commands. Installed to
.claude/commands/<name>.md, so a file namedanalyze.mdbecomes/analyze.
A companion gem declares in its manifest which gem each artifact targets and at which versions, so what lands in your app is what matches your Gemfile.lock, and nothing aimed at a library or version you don't run.
With no companion gems, hyperdrive:init sets up just the plumbing (.mcp.json, the engine mount, the bundler plugin line in your Gemfile, a .gitignore rule for the discover cache, an empty .hyperdrive/config.yml, and the lockfile) and puts nothing into your agent's context window. The two halves are independently skippable: --skip-mcp writes no .mcp.json entry and no mount; --skip-content skips the content, the config file, and the lockfile and leaves the rest. Neither removes anything already in place.
Staying in sync
After bundle install: automatic. hyperdrive:init registers the bundler-hyperdrive Bundler plugin in your Gemfile. From then on, adding a companion gem lands its artifacts on that very bundle install, with no extra command to run. The plugin is additive only (it never touches an existing file); version bumps and orphaned artifacts are only reported, with a pointer to hyperdrive:sync. It runs only where an install is safe to touch: a development environment (RAILS_ENV/RACK_ENV unset or development), no CI variable, no frozen bundle, and an app that already has a .hyperdrive/lock.yml (it tops up an initialized app, never bootstraps one). Because it never edits CLAUDE.md, a guideline that first arrives this way is not in context until the next hyperdrive:sync wires it in; the plugin says so when that happens.
bin/rails hyperdrive:sync: on demand. Run it any time (e.g. after bundle update) to refresh installed content to the current bundle. It touches no bootstrap artifact, and with no flags it leaves locally modified files untouched (skip + warn). When you have edited an installed file and its gem ships a new version, one flag picks the strategy:
| Flag | What happens to the live file | What happens to your edits | Falls back to |
|---|---|---|---|
| (none) | Untouched, with a warning naming the flags below | Kept | — |
--merge |
Rewritten with a git three-way merge (base = the gem version you last installed, ours = your file, theirs = the new upstream) when it applies cleanly | Kept |
--sidecar, whenever git cannot produce a clean result |
--sidecar |
Untouched; the new upstream body is written next to it as <file>.new
|
Kept, byte-for-byte | — |
--overwrite |
Restored to the gem-shipped content | Discarded | — |
--resolve |
Delivers as --sidecar (or as --merge, when both are given), then hands each unresolved <file>.new to a command you configure; exit 0 after the command changed the file deletes the sidecar |
Up to your command | — |
--merge, --sidecar, and --overwrite are mutually exclusive. --resolve stacks on top: alone it means "sidecar, then resolve", --merge --resolve means "git merges what it can, your command takes the rest", and --overwrite --resolve is rejected because an overwrite leaves nothing to resolve. Every combination accepts --dry-run, which prints the plan and writes nothing.
The fallback from --merge to a sidecar is what keeps a half-merged file from ever going live. It happens, with the reason on the status line, when the merge would need conflict markers; when the gem version you last installed is no longer on disk (CI, after gem cleanup), so there is no ancestor to merge against; when the file has no earlier install recorded in the lockfile; when the content is binary; or when git is not on your PATH. A clean merge is textually non-overlapping, not semantically correct: git merges two contradictory edits cleanly when unchanged lines sit between them, so git diff after a --merge run is a review step, not a formality.
A sidecar is inert (Claude Code loads only SKILL.md and the index.md @-lines, never a .new file), and it shows up in git status as your prompt to resolve. Resolve it by folding what you want into the live file and deleting the .new, or mv <file>.new <file> to accept the upstream wholesale. Either way the lockfile already records that delivery, so the next sync doesn't re-offer the same version (and a leftover sidecar you haven't touched is cleaned up once the live file catches up). A file that already has a sidecar is not out of reach: while one is pending the lockfile also records the version your edits were based on, so a later --merge still has a proper base to give git — including for the delivery already waiting in the .new.
The sidecar pair is also how an AI coding agent reconciles for you, with no extra machinery: run bin/rails hyperdrive:sync --sidecar, have the agent merge the live/.new pair semantically (it has both full texts), then delete the sidecar.
--resolve: hand the sidecars to a tool. --resolve automates that last step, git mergetool style. After delivery it hands every unresolved <file>.new — from this run or left over from an earlier one — to the command named in .hyperdrive/config.yml, and deletes the sidecar when that command exits 0 having changed the file. The gem ships no command of its own, so nothing runs that you did not name:
# .hyperdrive/config.yml
resolve:
command: claude -p $PROMPT --allowedTools Read,Edit,Writebin/rails hyperdrive:sync --resolve # every edited file goes to your tool
bin/rails hyperdrive:sync --merge --resolve # git merges what it can, your tool takes the rest
git diff # then you reviewThat form grants the tools rather than setting --permission-mode acceptEdits, because acceptEdits denies writes under .claude/, where every artifact lives, and cannot read $BASE, which is outside the app; $PROMPT comes first because --allowedTools takes every following argument.
The command is split with shell word rules and run with no shell, from the app root. Each argument gets these placeholders substituted, and every one is also exported as an environment variable ($LOCAL → HYPERDRIVE_LOCAL, and so on), so a wrapper script needs no argument parsing:
| Placeholder | Value |
|---|---|
$LOCAL |
The live file, with your edits |
$REMOTE |
The sidecar: the new upstream body |
$BASE |
The common ancestor — available whenever the lock records the pending delivery's ancestor and that gem version is still on disk; otherwise the argument is dropped |
$MERGED |
The live file again: the file your command must write |
$SOURCE |
<gem>@<version> of the new upstream |
$PREVIOUS_SOURCE |
<gem>@<version> your copy is based on — $BASE's source — when the lockfile records one |
$KIND |
skill, guideline, agent, command, or skill_support
|
$PROMPT |
Ready-made instructions for an agent, naming the paths above |
Replace the shipped prompt with your own ERB template — same placeholders, as lower-case locals (local, remote, base, merged, source, previous_source, kind) — with resolve.prompt: .hyperdrive/resolve-prompt.md.erb.
Exit 0 is honored only when the command changed $MERGED — git's own mergetool default (trustExitCode = false). Exit 0 with the file untouched is reported as command exited 0 but wrote nothing and, like any other exit — including a command that is not on your PATH — leaves the live file, the sidecar, and the lockfile exactly as they were, with the reason printed, so a failed resolve is just an ordinary unresolved sidecar. --resolve runs only on an explicit hyperdrive:sync, never during bundle install. --dry-run prints what it would hand off and runs nothing. A sidecar you edited yourself is never handed to the command.
bin/rails hyperdrive:discover: find what you're missing. Queries rubygems for companion gems published for your stack that you haven't installed yet, and prints the bundle add lines to run. It is read-only, caches results for 24h (--refresh re-queries), and never touches your Gemfile or makes network calls unless you invoke it.
You stay in charge
Everything lands git-tracked
CLAUDE.md # user-owned; ONE injected line: @.claude/hyperdrive/index.md
.claude/hyperdrive/
index.md # managed aggregator: @guidelines/<name>.md
guidelines/<name>.md # companion-shipped, frontmatter stripped
.claude/skills/<name>/
SKILL.md # companion-shipped, installed verbatim (frontmatter included)
<supporting files> # optional extras (references/, examples/, …), installed as shipped (*.md.erb rendered)
.claude/agents/<name>.md # companion-shipped subagent, installed verbatim
.claude/commands/<name>.md # companion-shipped slash command, installed verbatim
.hyperdrive/config.yml # yours to edit: disabled:/enabled:/resolve: settings
.hyperdrive/lock.yml # git-tracked manifest (source gem, version, content hash)
A git diff is where you review what a companion gem added. The install summary names each artifact's source gem and version, and every installed file is hashed and attributed to its source in the git-tracked .hyperdrive/lock.yml. The files themselves land byte-identical to what the gem ships, with nothing injected. hyperdrive:init and hyperdrive:sync warn if your app gitignores these paths, since that empties the diff without changing what reaches the agent. The hyperdrive:discover cache is the one file rails-hyperdrive adds to .gitignore. The lockfile carries a schema version, and a rails-hyperdrive older than the one that wrote it stops with an upgrade message instead of rewriting state it cannot read. A lockfile that cannot be opened, does not parse, or has something other than a map at its root stops the run the same way, naming the file so you can fix it or restore it from git.
CLAUDE.md and index.md are the eager chain: they exist only because a companion gem ships a guideline, and both go when the last one leaves the bundle (the guideline file itself is left on disk and reported as an orphan). A CLAUDE.md you had before hyperdrive:init gets the one line appended, and tear-down strips just that line; a CLAUDE.md the installer created is deleted only while it is still byte-identical to what was written.
Both links are yours to cut. Delete the @.claude/hyperdrive/index.md line from CLAUDE.md and no later run re-adds it (the lockfile remembers the choice, and the next run says so once). Delete a single @guidelines/<name>.md line from index.md and that guideline leaves eager context while staying installed; it is not re-added either, and an index.md you have emptied this way is kept because it is the record of those choices.
Your edits win
Installed files are yours to modify. The lockfile hash tells the installer whether a file is still gem-pristine: unedited files are refreshed on upgrade, edited files are skipped with a warning, never silently overwritten. hyperdrive:sync --overwrite is the explicit way back to gem-shipped content; --merge and --sidecar (above) bring the upstream change in without losing yours.
Turning off a single artifact
A companion gem you want for one skill but not another doesn't have to be all-or-nothing. Add the artifact's name to the disabled: list in .hyperdrive/config.yml — hyperdrive:init creates that file with empty sections, so the shape is already there:
disabled:
skills:
- sidekiq-idempotency
guidelines:
- jobs-sidekiq
agents:
- sidekiq-reviewer
commands:
- analyzeA disabled artifact is never installed, and one already on disk is removed on the next hyperdrive:init or hyperdrive:sync, but only if you haven't edited it. A locally modified file is reported and left alone, for you to delete when you're ready. Disabling a skill removes its shipped supporting files under the same per-file rule; files you created yourself in the skill directory survive and keep the directory alive. Disabling a guideline also drops its line from index.md, so it leaves eager context along with the file.
The list is yours to edit; the generator only reads it. Delete a name to get the artifact back on the next run. When two companion gems ship the same artifact name, both install under a <name>--<source-gem> suffix: the plain name disables both, the suffixed name disables one.
Earlier releases kept disabled: and enabled: in .hyperdrive/lock.yml. They are no longer read from there: a lock still carrying them draws one warning and the next write drops them. Move the lists into .hyperdrive/config.yml first: otherwise every artifact you had disabled installs again, and a gem that was enabled only there stops counting as a companion, so the next init or sync removes its unedited artifacts as stale.
To skip installed content wholesale instead, pass --skip-content to hyperdrive:init.
Opting into a gem's bundled skills
Ordinary gems (not built as hyperdrive companions) sometimes ship a top-level skills/ directory of skills.sh-style skills. Those are never installed automatically: hyperdrive:init and hyperdrive:sync only report them, e.g. gem 'foo' ships 2 skills.sh skill(s). To install them, name the gem in the enabled: list in .hyperdrive/config.yml and re-run hyperdrive:sync:
enabled:
- fooAn enabled gem is treated as a companion from then on: its skills install through the normal pipeline (including on bundle install), and disabled: still wins for any individual artifact. The list is hand-edited like disabled:; .hyperdrive/config.yml is yours alone, and no hyperdrive command writes to it after hyperdrive:init creates it.
Safety
Rails Hyperdrive is dev-only, enforced in depth: the engine refuses requests outside Rails.env.development?, applies an origin allowlist (localhost, 127.0.0.1, [::1]), and every tool re-checks the dev guard on invocation. The route line hyperdrive:init writes is itself guarded by Rails.env.development?, and an engine that loads outside development (the gem in the wrong Gemfile group, say) only logs a warning at boot. run_sql accepts read-only statements and refuses anything else. See SECURITY.md.
Build a companion gem
Ship markdown, declare what it targets, publish. That's the whole contract:
skills/<name>/SKILL.md # skill (dir-per-skill, may ship supporting files)
agents/<name>.md # agent (flat file)
commands/<name>.md # command (flat file)
lib/<gem_name>/hyperdrive/guidelines/<name>.md # guideline (flat file)
<gem_name> is your gem's name exactly as published, dashes and all — rails-hyperdrive-sidekiq ships guidelines under lib/rails-hyperdrive-sidekiq/hyperdrive/guidelines/, not lib/rails/hyperdrive/sidekiq/.
Top-level skills/, agents/, and commands/ are the tool-agnostic face of your gem — the same sibling layout .claude/ has, so relative links between them keep resolving once installed. They are readable by skills.sh and plain git-clone consumers as well as hyperdrive, and are scanned once your gem has opted in (below). lib/<gem_name>/hyperdrive/ is the hyperdrive-specific root: guidelines, and ERB skill templates (SKILL.md.erb, which must stay out of skills/ so raw ERB never reaches generic consumers). Plain skills shipped under it remain scanned as well. Guidelines, agents, and commands may ship as <name>.md.erb in their own roots — rendered against the app's bundle at discovery and installed as <name>.md, with a static file of the same name taking precedence.
Frontmatter is pure skills.sh: only name and description are read, so a skill repo's content integrates without modification:
---
name: jobs-sidekiq # kebab-case; determines the install path
description: Background job conventions for Sidekiq.
---Agents follow the same contract. Commands are the exception: their frontmatter is optional and never validated, and their identity is the filename stem.
Gating (which bundles an artifact installs into) lives in a hyperdrive.yml manifest at the gem root (or at the path named by a hyperdrive_manifest gemspec metadata key), never in the content. Every key is optional; no manifest (or an empty one) means everything installs universally:
gems: # gem-wide default: TARGET gem(s), resolved in the bundle ("gem:" is an alias)
- sidekiq: ">= 7.0, < 9.0" # a list member is a bare name, or a name: requirement pair
skills: # per-skill overrides, keyed by skill dir relative to its skills root
jobs-sidekiq:
gems:
- sidekiq: ">= 8.0"
hyperdrive_version: ">= 0.7" # require a minimum rails-hyperdrive for this artifact
guidelines: # per-guideline overrides, keyed by filename
jobs.md:
gem: sidekiq
agents: # per-agent overrides, keyed by filename
sidekiq-reviewer.md:
gem: sidekiq
commands: # per-command overrides, keyed by filename
command_prefix: sidekiq # optional; every command installs as <prefix>-<filename>
analyze.md:
gem: sidekiqA gate naming several targets can be written as a map with one of any:/all: — gems: {any: [sidekiq, solid_queue]} installs when either is bundled, gems: {all: [devise, pundit]} only when both are. A bare list is shorthand for any:, and an entry's gate replaces the gem-wide default wholesale.
hyperdrive_version: is valid at the top level too, and is matched against the running rails-hyperdrive rather than the bundle — the sanctioned way to fence content that needs a newer installer. It fences on whichever gem implements artifact discovery and install — today rails-hyperdrive — and that version numbering is guaranteed continuous across any future restructuring of the gem, so a fence like ">= 0.8" keeps its meaning permanently.
Shipping a hyperdrive.yml (or declaring hyperdrive_manifest) opts your gem in as a companion. Also declare your targets in gemspec metadata. That opts your gem in too, and it is the pre-install targeting signal (how hyperdrive:discover suggests you before anyone installs you):
spec.metadata["hyperdrive_targets"] = "sidekiq"Companion repos get author-side CI checks by adding require "hyperdrive/skill_tasks" to the Rakefile: rake hyperdrive:skills:check keeps generated skill content in step with its templates, and rake hyperdrive:manifest:check lints hyperdrive.yml strictly.
docs/COMPANION_GEMS.md has the full contract: multi-target artifacts, multi-file skills, per-file gem gating, ERB-templated content, the template/content paired layout that also serves npx skills and git-clone consumers, and the collision and dedup rules.
Requirements
Ruby ≥ 3.2, Rails ≥ 7.2. Tested against Rails 7.2 and 8.1 on Ruby 3.2-3.4.
License
MIT. See LICENSE.txt.
