feature_machine
Feature requests, work items, and the judge pipeline, packaged as a Rails engine.
Your users file feature requests and bug reports, on your site or through their own agents over MCP. You triage them into work items, a judge ranks and groups them, and your own coding agent can pick up a planned item over MCP and attach the pull request for you to approve.
Requirements
- Ruby 3.3+, Rails 8.1+
- Postgres with the pgvector extension
-
ruby_llm2.1+ (only called if you name a model; see Models)
Install
# Gemfile
gem "feature_machine"bundle install
bin/rails g feature_machine:install
bin/rails feature_machine:install:migrations
bin/rails db:migrate
The generator writes config/initializers/feature_machine.rb and draws the
engine's routes into config/routes.rb:
feature_machine_routes :web # /feature-requests, /bug-reports, /roadmap
namespace :admin do
feature_machine_routes :admin # /admin/triage, /admin/work_items
end
namespace :api, defaults: { format: :json } do
feature_machine_routes :api # /api/feature_requests, /api/bug_reports, /api/roadmap,
end # /api/work_items, /api/maintainer/feature_requests
feature_machine_routes :mcp # POST /mcp, for agentsThen tell the engine three things about your app in the initializer:
FeatureMachine.configure do |config|
# Who is signed in. With Rails' authentication generator:
config.current_user = ->(request) { Session.find_by(id: request.cookie_jar.signed[:session_id])&.user }
# Who may triage. The work-items migration adds users.admin, so the default works.
config.admin = ->(user) { user.admin? }
# Bearer token -> credential: any object answering `user` and `allows?(scope)`
# for "read", "write" and "maintainer". Without it, agents can't connect.
config.authenticate_api = ->(token) { ApiToken.authenticate(token) }
endUpgrading an app that drew the routes by hand
The route macro now draws everything the engine ships: bug reports, the
roadmap, triage, attachments, and their APIs. If your config/routes.rb still
draws any of those next to feature_machine_routes, delete your copies, or
Rails stops at boot with "Invalid route name, already in use".
The engine's controllers no longer inherit Admin::BaseController or
Api::BaseController on their own. If yours add filters, a layout or headers
the engine's pages should keep, point the engine at them:
config.admin_parent_controller = "Admin::BaseController"
config.api_parent_controller = "Api::BaseController"Your API base keeps its own render_error. The engine reads the signed-in API
user through its own api_user, so your current_user is left alone.
Host contract
That is everything a fresh rails new app needs. The engine brings its own
admin and API base controllers, so the host doesn't need current_user,
Admin::BaseController, Api::BaseController or a has_many on its user
model.
-
Postgres with pgvector. The migrations enable the
vectorextension. -
A
userstable.feature_requests.user_idreferences it, and the work-items migration addsusers.admin. Name another model withconfig.user_class. -
Pages inherit your
ApplicationController, so your layout and auth filters apply. Signed-out visitors go tonew_session_pathwhen you have one (config.sign_in_path) and get a 401 otherwise. To inherit something else, setconfig.parent_controller,config.admin_parent_controllerorconfig.api_parent_controller. An API base of your own keeps its ownrender_errorand error handling. -
The engine's pages use
cn-*classes and ship plain default styles for them, inlined in a@layer feature_machineblock, so a host without a design system still renders cleanly and anycn-*rule of yours wins without!important. Setconfig.default_stylesheet = falseif your own stylesheet covers them all. They show their own flash notices, because a new app's layout doesn't; setconfig.flash_in_pages = falseif yours does. -
Your own roadmap or bug board replaces the engine's by pointing
config.roadmap_path(and friends) at it. -
An MCP server of your own can skip
feature_machine_routes :mcpand addFeatureMachine::Mcp.for(credential)to its tool list instead. A browser session cookie is never an MCP credential; only bearer tokens are.
Models
Embeddings, the spec drafter and the triage judge are each off until you name a
RubyLLM model (config.embedding_model, config.drafter_model,
config.judgment_model). If a model is named but its provider key is missing
or the call fails, the feature steps aside: requests are still filed, triage
shows unranked groups, and forming a work item writes a plain digest instead
of a spec.
The triage judge uses RubyLLM's structured judgments, which only some providers
implement: OpenAI, Ollama, and TypeSafe's Jev models in ruby_llm 2.1. Pick a
judgment_model one of them serves. A chat model named through OpenRouter
can't judge: triage logs a warning and stays unranked.
OpenRouter does serve Jev's judgment API, so an OpenRouter key is enough:
RubyLLM.configure do |c|
c.typesafe_api_base = "https://openrouter.ai/api"
c.typesafe_api_key = ENV["OPENROUTER_API_KEY"]
end
FeatureMachine.config.judgment_model = "jev-latest"For a local
Jev-compatible server,
point typesafe_api_base at it instead and set
config.judgment_provider = :typesafe, so the server's own model id resolves
without a registry entry. Embeddings and the drafter work on any provider RubyLLM
supports, including OpenRouter (set config.drafter_provider = :openrouter).
Requests filed before embedding_model was set never got an embedding, and
triage leaves them out of every group. Embed them once you switch it on:
bin/rails feature_machine:embed_requests
bin/rails feature_machine:calibrate_judge checks your judgment_model
against mutated cases: paraphrases mustn't move an answer, and changes that
alter the meaning must. It spends tokens, so it never runs in CI.
In November 2026, jev-latest held 18 of 18. Rerun it when you change models.
Agents over MCP
feature_machine_routes :mcp serves POST /mcp. Agents authenticate with a
bearer token through config.authenticate_api; a browser session cookie is
never accepted there.
Any token with read (and write for filing) sees:
| Tool | Does |
|---|---|
list_feature_requests |
The user's own requests, with status and the maintainer's note |
request_feature |
File a request on the user's behalf |
list_bug_reports |
The user's own bug reports, with status and note |
report_bug |
File a bug on the user's behalf |
read_roadmap |
In-progress and planned work, then the changelog |
A maintainer token whose user also passes config.admin additionally sees
list_work_items, get_work_item and update_work_item. Those aren't
advertised to anyone else, and the engine re-checks config.admin on every
maintainer call rather than trusting the token's scope alone. An agent can
move an item through its statuses and attach a pull request, but it can't
edit the spec, approve drafts or decline work.
Configuration reference
All set in config/initializers/feature_machine.rb. Lambdas are called per
request or per record; strings naming classes are constantized lazily, so the
initializer never autoloads app code.
| Setting | Default | What it does |
|---|---|---|
current_user |
->(request) { nil } |
The signed-in user for a request |
admin |
user.admin? if defined |
May this user triage? |
authenticate_api |
nil |
Bearer token to credential (user, allows?(scope)). nil = no API or MCP access |
session_auth |
true |
Let a browser session act as a credential on the JSON API (never MCP) |
user_class |
"User" |
The model feature_requests.user_id points at |
user_label |
"user #<id>" |
How a requester is named on admin pages and in specs |
product_name |
"this app" |
Used in copy and error messages |
product_blurb |
product_name |
One line describing the product to the drafter |
token_help_url |
nil |
Where scope errors tell users to get a token |
protected_resource_url |
nil |
OAuth protected-resource metadata URL for 401 WWW-Authenticate
|
embedding_model |
nil |
RubyLLM embedding model. nil = no similarity grouping |
drafter_model, drafter_provider, drafter_fallbacks
|
nil, nil, []
|
Chat model that writes work-item specs. nil = plain digest |
judgment_model, judgment_provider
|
nil, nil
|
Structured-judgment model for triage. nil = unranked |
on_status_change |
nil |
->(request) { ... } after a request's status changes (mail the requester, etc.) |
parent_controller |
"ApplicationController" |
Base for engine pages |
admin_parent_controller |
parent_controller |
Base for engine admin pages |
api_parent_controller |
"ActionController::API" |
Base for engine API controllers |
layout |
host's own | Layout for engine pages; "feature_machine" is a minimal standalone shell |
admin_nav_partial |
nil |
Host partial rendered atop engine admin pages, e.g. "admin/nav"
|
sign_in_path |
new_session_path if drawn |
->(controller) { ... } where signed-out visitors go |
flash_in_pages |
true |
Engine pages render their own flash notices |
default_stylesheet |
true |
Engine pages inline default cn-* styles (in a CSS layer your rules override) |
roadmap_path, bug_reports_path, new_bug_report_path, admin_triage_path
|
engine's pages |
->(view, ...) { ... } to link your own pages instead; nil drops the link |
License
MIT. See LICENSE.