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.
Installs into .claude/skills of the current project.
Are you the author of Plutonium App?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/radioactive-labs-plutonium-app)
---
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:
1. **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`.
2. **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.
3. **Auth per portal.** `--auth=<account>` / `--public` / `--byo` / `--scope=<Entity>` (multi-tenant). Unguessable from "admin area" ā decide, don't default silently.
4. **Don't stop half-wired.** A resource reaches the browser only after: scaffold ā migrate ā `pu:res:conn --dest=portal` ā portal engine `mount`ed 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)
```bash
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`.
```bash
# Option 1 ā template
bin/rails app:template \
LOCATION=https://radioactive-labs.github.io/plutonium-core/templates/base.rb
# Option 2 ā manual
# Add `gem "plutonium"` to Gemfile, then:
bundle install
rails generate pu:core:install
```
## Full setup workflow
```bash
# 1. Core install (base controllers/policies/definitions/layouts)
rails generate pu:core:install
# 2. Auth (if needed)
rails generate pu:rodauth:install
rails generate pu:rodauth:account user
# 3. Portal
rails generate pu:pkg:portal admin --auth=user
# 4. First resource
rails generate pu:res:scaffold Post user:belongs_to title:string 'content:text?' --dest=main_app
rails db:prepare
# 5. Connect resource to portal
rails generate pu:res:conn Post --dest=admin_portal
# 6. Mount portal in config/routes.rb
# mount AdminPortal::Engine, at: "/admin"
# 7. Start
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
```ruby
# 1. Include the module on your model
class Post < ApplicationRecord
include Plutonium::Resource::Record
end
```
```bash
# 2. Generate supporting files (skips model + migration)
rails g pu:res:scaffold Post --no-migration --dest=main_app
# 3. Connect to portal
rails g pu:res:conn Post --dest=admin_portal
```
## Configuration
```ruby
# config/initializers/plutonium.rb
Plutonium.configure do |config|
config.load_defaults 1.0
# Page chrome. Default :modern (topbar + icon rail).
# :classic preserves the legacy header + sidebar (only when upgrading).
# config.shell = :classic
# Custom assets
# config.assets.stylesheet = "custom_stylesheet"
# config.assets.script = "custom_script"
# config.assets.logo = "custom_logo.png"
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
```bash
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:
```ruby
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
```bash
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:
```bash
rails g pu:res:scaffold Comment user:belongs_to blogging/post:belongs_to body:text --dest=comments
```
## Portal packages
```bash
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`):
```ruby
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.
## Locale files
Packages and portals are Rails engines, so `packages/<name>/config/locales/*.yml` loads automatically, ahead of the app's own `config/locales` (app wins, then packages, then the gem's defaults). Both generators scaffold `config/locales/en.yml`.
- Feature package: model/attribute names (`activerecord.models`, `activerecord.attributes`) and field text (`plutonium.fields.<model>.<attr>.{placeholder,hint,description}`).
- Portal: portal-specific wording under `plutonium.portals.<portal_namespace>.ā¦` (same tree as the global keys: `fields`, `actions`, `scopes`, `filters`, `kanban_columns`, `values`). Wins over the global key only inside that portal.
- Any of Plutonium's own strings (`plutonium.*` in the gem's `config/locales/en/*.yml`) can be overridden from either place. Pagination text comes from Pagy's dictionaries instead.
Switch locale with a normal Rails `around_action` in the portal's controller concern; Plutonium reads `I18n.locale` and never sets it. See `docs/reference/i18n.md`.
## 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
```bash
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`) |
```bash
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
```ruby
# packages/admin_portal/lib/engine.rb
module AdminPortal
class Engine < Rails::Engine
include Plutonium::Portal::Engine
config.after_initialize do
# Optional: multi-tenancy. See plutonium-tenancy for strategies.
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
```ruby
module AdminPortal::Concerns::Controller
extend ActiveSupport::Concern
include Plutonium::Portal::Controller
include Plutonium::Auth::Rodauth(:user)
end
```
### Public access
```ruby
module AdminPortal::Concerns::Controller
extend ActiveSupport::Concern
include Plutonium::Portal::Controller
include Plutonium::Auth::Public
end
```
### BYO auth
```ruby
module AdminPortal::Concerns::Controller
extend ActiveSupport::Concern
include Plutonium::Portal::Controller
include Plutonium::Auth::Public # disables Rodauth requirement
def current_user
@current_user ||= User.find_by(api_key: request.headers["X-API-Key"])
end
end
```
## Mounting
```ruby
# config/routes.rb
Rails.application.routes.draw do
# Authenticated mount
constraints Rodauth::Rails.authenticate(:user) do
mount AdminPortal::Engine, at: "/admin"
end
# Unconstrained (portal handles its own auth)
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.
```ruby
# Feature controller exists ā inherit from it AND include portal concern
class AdminPortal::PostsController < ::PostsController
include AdminPortal::Concerns::Controller
end
# No feature controller ā inherit from portal's ResourceController
class AdminPortal::PostsController < AdminPortal::ResourceController
end
```
For non-resource portal pages (dashboard, settings):
```ruby
module AdminPortal
class DashboardController < PlutoniumController
def index; end
end
end
```
## Per-portal overrides
```ruby
# Definition ā how fields render (NOT whether they appear)
class AdminPortal::PostDefinition < ::PostDefinition
scope :pending_review
input :internal_notes, hint: "Not shown to the author"
end
# Policy ā whether a field appears at all
class AdminPortal::PostPolicy < ::PostPolicy
include AdminPortal::ResourcePolicy
def destroy? = true
def permitted_attributes_for_create = %i[title content featured internal_notes]
end
# Controller
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`:
```ruby
AdminPortal::Engine.routes.draw do
root to: "dashboard#index"
register_resource ::Post
register_resource Blogging::Comment
# Non-resource pages
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.:
```ruby
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`:
```bash
rails g pu:res:conn Profile --dest=customer_portal --singular
```
## Dashboards
`register_dashboard SomeDashboard, at: "overview"` mounts a `Plutonium::Dashboard::Base` subclass (metric / chart / free-form cards, each in its own lazy turbo frame) on the portal at `/dashboards/overview`; `at: "/"` (generator: `--at=/`) makes it the portal root in place of `root to: "dashboard#index"`; delete the generated `DashboardController` and view afterwards. Generate one with `rails g pu:dashboard Overview --dest=<portal>`. See [[plutonium-dashboard]].
## Custom member / collection routes
```ruby
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
```bash
rails g pu:res:conn RESOURCE [RESOURCE...] --dest=PORTAL_NAME [--singular]
```
Pass resources directly ā avoids interactive prompts. No `--src` needed.
## Usage
```bash
# Main app resources
rails g pu:res:conn Post Comment Tag --dest=admin_portal
# Namespaced (from a feature package)
rails g pu:res:conn Blogging::Post Blogging::Comment --dest=admin_portal
# Singular (profile, settings, dashboard)
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`:
```ruby
register_resource ::Post
register_resource ::Profile, singular: true # if --singular
```
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
```ruby
class AdminPortal::PostsController < ::PostsController
include AdminPortal::Concerns::Controller
end
```
### Generated policy (seeded from model columns)
```ruby
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