0.0
The project is in a healthy, maintained state
A mountable Rails engine for the feature-request -> triage -> work-item -> agent hand-off loop.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Runtime

>= 2.11
>= 1.6
>= 1.0
>= 8.1
>= 2.1
 Project Readme

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_llm 2.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 agents

Then 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) }
end

Upgrading 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 vector extension.
  • A users table. feature_requests.user_id references it, and the work-items migration adds users.admin. Name another model with config.user_class.
  • Pages inherit your ApplicationController, so your layout and auth filters apply. Signed-out visitors go to new_session_path when you have one (config.sign_in_path) and get a 401 otherwise. To inherit something else, set config.parent_controller, config.admin_parent_controller or config.api_parent_controller. An API base of your own keeps its own render_error and error handling.
  • The engine's pages use cn-* classes and ship plain default styles for them, inlined in a @layer feature_machine block, so a host without a design system still renders cleanly and any cn-* rule of yours wins without !important. Set config.default_stylesheet = false if your own stylesheet covers them all. They show their own flash notices, because a new app's layout doesn't; set config.flash_in_pages = false if 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 :mcp and add FeatureMachine::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.