| name | plutonium-app |
| description | Use BEFORE installing Plutonium, creating a portal or feature package, mounting an engine, or registering resources/routes. Covers initial setup, the package system, portal engines, route registration (including singular and custom routes), and resource-to-portal wiring. |
Plutonium App — Installation, Packages, Portals, Routes
How a Plutonium app is assembled: the install bootstrap, the package system (feature vs portal), portal engines, and the routing surface that exposes resources to the web.
For the resources themselves (model + definition + scaffold options), see [[plutonium-resource]]. For controllers/policies/interactions, see [[plutonium-behavior]]. For multi-tenancy, see [[plutonium-tenancy]].
🚨 Critical (read first)
- Use the generators for everything.
pu:core:install, pu:rodauth:install, pu:pkg:portal, pu:pkg:package, pu:res:scaffold, pu:res:conn. Never hand-write base controllers, engine files, layouts, or portal route registration.
- Existing app →
base.rb. New app → plutonium.rb. The plutonium.rb template re-runs full bootstrap (dotenv, annotate, solid_*, asset config) and creates generic "initial commit" commits that clobber history. For any pre-existing app use base.rb.
- Pass
--dest, --auth, --force, --skip-bundle etc. for unattended runs so generators don't block on prompts.
- Feature vs portal is a hard split. Feature packages hold models/policies/definitions/interactions. Portal packages hold controllers/views/routes/auth. Don't mix.
- Package classes are auto-namespaced —
packages/blogging/app/models/blogging/post.rb → Blogging::Post. Don't fight it.
- Always connect resources with
pu:res:conn — until connected, a resource has no portal routes and is invisible.
- For custom routes on a registered resource, pass
as: — otherwise resource_url_for can't build URLs.
🛑 Before you install or scaffold structure: confirm the shape (ASK — don't infer)
"Set up Plutonium" / "make an admin area" / "create a billing package" each hide a high-blast-radius decision. Get one wrong and you clobber a year of git history, build the wrong kind of package, or ship a portal nobody can log into. Resolve each — confirming by inspection (next section), not assumption:
- Fresh app or existing one? Existing ⇒
bundle add plutonium + pu:core:install (the base.rb path). NEVER the plutonium.rb fresh-app template on an existing app — it re-bootstraps (dotenv/annotate/solid_*/assets) and drops "initial commit" commits that clobber history. This is the single most dangerous mistake in this skill — confirm it's greenfield before reaching for plutonium.rb.
- Feature package or portal package? Business logic (models/policies/definitions/interactions) ⇒
pu:pkg:package (feature, no UI). A web surface (controllers/views/routes/auth) ⇒ pu:pkg:portal. Hard split — "billing" is a feature; "admin area" is a portal. A feature package is invisible until its resources are pu:res:conn'd into a portal.
- Auth per portal.
--auth=<account> / --public / --byo / --scope=<Entity> (multi-tenant). Unguessable from "admin area" — decide, don't default silently.
- Don't stop half-wired. A resource reaches the browser only after: scaffold → migrate →
pu:res:conn --dest=portal → portal engine mounted in config/routes.rb → registered (conn does the last). Name the whole chain before you start.
Never ship a guessed schema, portal name, or auth flag as applied commands — read them off the app first; fall back to AskUserQuestion only for genuine product choices (separate staff accounts vs shared, which payment backend). The decisions compound: existing app ⇒ base.rb path; feature package ⇒ needs a portal to be visible; new portal ⇒ pick auth + mount it.
✅ Before you run a generator: verify the ground truth (CHECK — read it, don't ask for it)
You have file access — inspect; don't ask the user to describe their app.
| Check | How | Why it matters |
|---|
| Greenfield vs existing | git log --oneline | head; is there a populated Gemfile/app/? | An existing app must use the base.rb path — never plutonium.rb |
| Plutonium already installed | grep Gemfile for plutonium; ls config/packages.rb app/controllers/resource_controller.rb | Avoid re-installing / double bootstrap |
| Package/portal already exists | ls packages/<name> | Don't duplicate — connect to / extend the existing one |
| Existing auth | grep Gemfile/app/models for rodauth/devise/has_secure_password | Drives --auth vs --byo |
| Portal engine mounted | grep config/routes.rb for mount <Portal>::Engine | An unmounted portal 404s |
| Resource registered | grep the portal's config/routes.rb for register_resource ::<X> | Unregistered ⇒ no URLs (resource_url_for fails) |
| Migrations applied | rails db:migrate:status before pu:res:conn | conn seeds the policy from columns |
Inspect with your own tools before running any generator.
🛠 Use the generator — pick the right install path
Never hand-write base controllers, engine files, layouts, or route registration. Pass --dest/--auth/--force/--skip-bundle for unattended runs.
| Task | Generator | Verify first |
|---|
| Install — existing app | bundle add plutonium + pu:core:install | It's an existing app (use base.rb, not plutonium.rb) |
| Install — fresh app | rails new … -m …/plutonium.rb | Brand-new app only |
| Feature package | pu:pkg:package <name> | Not already present |
| Portal package | pu:pkg:portal <name> --auth=…/--public/--byo/--scope=… | Auth strategy decided; then mount the engine by hand |
| Connect a resource | pu:res:conn <Res> --dest=portal | Migrated; target portal exists |
Part 1 — Installation
Fresh Rails app (recommended)
rails new myapp -a propshaft -j esbuild -c tailwind \
-m https://radioactive-labs.github.io/plutonium-core/templates/plutonium.rb
Configures Rails + Propshaft + esbuild + TailwindCSS + Plutonium in one shot.
Existing Rails app
⚠️ Use base.rb, not plutonium.rb.
bin/rails app:template \
LOCATION=https://radioactive-labs.github.io/plutonium-core/templates/base.rb
bundle install
rails generate pu:core:install
Full setup workflow
rails generate pu:core:install
rails generate pu:rodauth:install
rails generate pu:rodauth:account user
rails generate pu:pkg:portal admin --auth=user
rails generate pu:res:scaffold Post user:belongs_to title:string 'content:text?' --dest=main_app
rails db:prepare
rails generate pu:res:conn Post --dest=admin_portal
rails server
What pu:core:install creates
app/
├── controllers/
│ ├── plutonium_controller.rb # non-resource base
│ └── resource_controller.rb # CRUD base — see plutonium-behavior
├── definitions/resource_definition.rb
├── interactions/resource_interaction.rb
├── models/resource_record.rb # abstract model — includes Plutonium::Resource::Record
├── policies/resource_policy.rb
└── views/layouts/resource.html.erb
config/
├── initializers/plutonium.rb
└── packages.rb # auto-loads packages/**/lib/engine.rb
packages/.keep
The base classes (ResourceController, ResourcePolicy, ResourceDefinition, ResourceRecord, ResourceInteraction) are where you put app-wide defaults; resource-specific subclasses come from pu:res:scaffold.
Converting an existing model to a resource
class Post < ApplicationRecord
include Plutonium::Resource::Record
end
rails g pu:res:scaffold Post --no-migration --dest=main_app
rails g pu:res:conn Post --dest=admin_portal
Configuration
Plutonium.configure do |config|
config.load_defaults 1.0
end
Part 2 — The Package System
Two kinds, hard split:
| Type | Purpose | Generator | Examples |
|---|
| Feature | Business logic (models, policies, definitions, interactions, migrations) | pu:pkg:package NAME | blogging, billing, inventory |
| Portal | Web interface (controllers, views, routes, auth) | pu:pkg:portal NAME | admin_portal, customer_portal, public_portal |
Feature packages
rails g pu:pkg:package blogging
Structure:
packages/blogging/
├── app/
│ ├── models/blogging/ # Blogging::Post
│ ├── definitions/blogging/ # Blogging::PostDefinition
│ ├── policies/blogging/ # Blogging::PostPolicy
│ └── interactions/blogging/ # Blogging::PublishPostInteraction
├── db/migrate/
└── lib/engine.rb
Engine:
module Blogging
class Engine < Rails::Engine
include Plutonium::Package::Engine
end
end
Auto-namespacing: every file under app/<kind>/blogging/ resolves to Blogging::*.
Creating resources in a feature package
rails g pu:res:scaffold Blogging::Post title:string --dest=blogging
--dest=<package_name> puts model/migration in the package. Cross-package references use the full namespace:
rails g pu:res:scaffold Comment user:belongs_to blogging/post:belongs_to body:text --dest=comments
Portal packages
rails g pu:pkg:portal admin
Structure:
packages/admin_portal/
├── app/
│ ├── controllers/admin_portal/
│ │ ├── concerns/controller.rb # auth + shared filters
│ │ ├── dashboard_controller.rb
│ │ ├── plutonium_controller.rb
│ │ └── resource_controller.rb
│ ├── definitions/admin_portal/ # per-portal overrides
│ ├── policies/admin_portal/ # per-portal overrides
│ └── views/layouts/admin_portal.html.erb
├── config/routes.rb
└── lib/engine.rb
See Part 3 for engine configuration and Part 5 for resource connection.
Package loading
config/packages.rb (created by pu:core:install):
Dir.glob(File.expand_path("../packages/**/lib/engine.rb", __dir__)) do |package|
load package
end
This is loaded from config/application.rb. Migrations from all packages are picked up by rails db:migrate automatically.
When to use which
Feature packages — domain logic that:
- Could be reused across multiple portals (admin and customer both edit
Blogging::Post)
- Has no inherent UI / auth (it's just behavior)
- You want to keep isolated from other domains (
billing should not depend on blogging)
Portal packages — user-facing surfaces that:
- Have a specific auth flow (admin vs customer vs public)
- Render different views of the same underlying resources
- Need different policies / definitions per audience
Part 3 — Portal Engines
A portal is a Rails engine mixing in Plutonium::Portal::Engine. It defines its own routes, controller concern, and (optionally) entity scoping.
Generator
rails g pu:pkg:portal <name>
Options
| Option | Description |
|---|
--auth=NAME | Rodauth account to use (e.g. --auth=user) |
--public | Public access — no auth |
--byo | Bring your own auth |
--scope=CLASS | Entity class for multi-tenancy (e.g. --scope=Organization) |
rails g pu:pkg:portal admin --auth=admin
rails g pu:pkg:portal api --public
rails g pu:pkg:portal custom --byo
rails g pu:pkg:portal admin --auth=admin --scope=Organization
Without flags, the generator prompts interactively.
Engine file
module AdminPortal
class Engine < Rails::Engine
include Plutonium::Portal::Engine
config.after_initialize do
scope_to_entity Organization, strategy: :path
end
end
end
Controller concern (auth)
Every portal has a Concerns::Controller mixed into its ResourceController. The generator wires this up; you customize it for auth / before_action hooks.
Rodauth
module AdminPortal::Concerns::Controller
extend ActiveSupport::Concern
include Plutonium::Portal::Controller
include Plutonium::Auth::Rodauth(:user)
end
Public access
module AdminPortal::Concerns::Controller
extend ActiveSupport::Concern
include Plutonium::Portal::Controller
include Plutonium::Auth::Public
end
BYO auth
module AdminPortal::Concerns::Controller
extend ActiveSupport::Concern
include Plutonium::Portal::Controller
include Plutonium::Auth::Public
def current_user
@current_user ||= User.find_by(api_key: request.headers["X-API-Key"])
end
end
Mounting
Rails.application.routes.draw do
constraints Rodauth::Rails.authenticate(:user) do
mount AdminPortal::Engine, at: "/admin"
end
mount PublicPortal::Engine, at: "/public"
end
Controller hierarchy
Portal controllers inherit from the feature-package controller if one exists, OR from the portal's ResourceController otherwise.
class AdminPortal::PostsController < ::PostsController
include AdminPortal::Concerns::Controller
end
class AdminPortal::PostsController < AdminPortal::ResourceController
end
For non-resource portal pages (dashboard, settings):
module AdminPortal
class DashboardController < PlutoniumController
def index; end
end
end
Per-portal overrides
class AdminPortal::PostDefinition < ::PostDefinition
input :internal_notes, as: :text
scope :pending_review
end
class AdminPortal::PostPolicy < ::PostPolicy
include AdminPortal::ResourcePolicy
def destroy? = true
def permitted_attributes_for_create = %i[title content featured internal_notes]
end
module AdminPortal
class PostsController < ResourceController
private
def preferred_action_after_submit = "index"
end
end
Part 4 — Routes & register_resource
Portal routes live in packages/<name>_portal/config/routes.rb:
AdminPortal::Engine.routes.draw do
root to: "dashboard#index"
register_resource ::Post
register_resource Blogging::Comment
get "settings", to: "settings#index"
end
register_resource — what it does
For each call, Plutonium auto-generates:
- Top-level CRUD routes (
/posts, /posts/:id, etc.)
- Nested routes for every registered
has_many / has_one parent (prefixed nested_)
- Route names that
resource_url_for can resolve
You list every resource the portal exposes. If a resource isn't registered, it has no URLs in that portal — resource_url_for will fail.
Singular (singleton) resources
For resources with no collection — a single per-user Profile, app-wide Settings, etc.:
register_resource ::Profile, singular: true
Generates singular routes (no :id, no index):
GET /profile → show
GET /profile/new → new
GET /profile/edit → edit
POST /profile → create
PATCH /profile → update
DELETE /profile → destroy
Use the --singular flag on pu:res:conn:
rails g pu:res:conn Profile --dest=customer_portal --singular
Custom member / collection routes
register_resource ::Post do
member do
get :preview, as: :preview
get :analytics, as: :analytics
post :publish, as: :publish
end
collection do
get :archived, as: :archived
post :bulk_publish, as: :bulk_publish
end
end
Always pass as:. Without it, resource_url_for(@post, action: :preview) fails because there's no named route to look up — especially critical for nested resources.
For most operations with business logic, prefer interactive actions (definition + interaction — see [[plutonium-resource]] › Actions) over custom controller routes. The action routes are wired automatically with no register_resource block needed.
Cross-package and nested URLs
See [[plutonium-behavior]] for full resource_url_for signature and [[plutonium-tenancy]] for nested routing semantics.
Part 5 — Connecting Resources to Portals (pu:res:conn)
A resource is invisible until connected to at least one portal. The generator wires up the portal-specific controller, policy, definition, and route registration.
Command syntax
rails g pu:res:conn RESOURCE [RESOURCE...] --dest=PORTAL_NAME [--singular]
Pass resources directly — avoids interactive prompts. No --src needed.
Usage
rails g pu:res:conn Post Comment Tag --dest=admin_portal
rails g pu:res:conn Blogging::Post Blogging::Comment --dest=admin_portal
rails g pu:res:conn Profile --dest=customer_portal --singular
Run after migrations — the generator reads model columns to seed the policy's permitted_attributes_for_*.
What gets generated
For Post connected to admin_portal:
packages/admin_portal/app/
├── controllers/admin_portal/posts_controller.rb
├── policies/admin_portal/post_policy.rb
└── definitions/admin_portal/post_definition.rb
Plus route registration appended to packages/admin_portal/config/routes.rb:
register_resource ::Post
register_resource ::Profile, singular: true
Re-running pu:res:conn for the same resource is idempotent — already-registered entries report identical and are not duplicated. Insertion falls back gracefully when the conventional # register resources above marker is missing (uses the routes.draw do opening), and warns clearly if it can't find any anchor.
Generated controller
class AdminPortal::PostsController < ::PostsController
include AdminPortal::Concerns::Controller
end
Generated policy (seeded from model columns)
class AdminPortal::PostPolicy < ::PostPolicy
include AdminPortal::ResourcePolicy
def permitted_attributes_for_create
[:title, :content, :user_id]
end
def permitted_attributes_for_read
[:title, :content, :user_id, :created_at, :updated_at]
end
def permitted_associations
%i[]
end
end
Review and trim — the generator is liberal. Especially: drop _id fields when the form uses the association name, and add :price (not :price_cents) for has_cents fields.
Generator reference
| Generator | Purpose |
|---|
pu:core:install | Initial Plutonium setup (base classes, config, layouts) |
pu:rodauth:install | Set up Rodauth auth |
pu:rodauth:account NAME | Create user account type |
pu:rodauth:admin NAME | Admin account with 2FA, lockout, audit |
pu:saas:setup | User + entity + membership in one shot |
pu:saas:user NAME | SaaS user account |
pu:saas:entity NAME | Entity model |
pu:saas:membership | Membership join table |
pu:pkg:package NAME | Feature package |
pu:pkg:portal NAME | Portal package |
pu:res:scaffold NAME | Resource (model, migration, controller, policy, definition) |
pu:res:conn NAME | Connect resource to portal |
pu:invites:install | Invite system (see [[plutonium-tenancy]]) |
pu:invites:invitable NAME | Mark a model as invitable |
pu:eject:layout | Eject layouts for customization |
pu:eject:shell | Eject topbar/sidebar partials |
pu:core:update | Update plutonium gem + npm |
pu:skills:sync | Sync Claude Code skills to project |
Related skills
- [[plutonium-resource]] — what a resource IS (model + definition + scaffold options)
- [[plutonium-behavior]] — controllers, policies, interactions
- [[plutonium-tenancy]] — entity scoping, nested resources, invites
- [[plutonium-auth]] — Rodauth account configuration
- [[plutonium-ui]] — layouts, page classes, custom Phlex components, assets
- [[plutonium-testing]] — testing portals, packages, controllers