An open source, Rails 8+ engine that provides convention-over-configuration CRUD scaffolding. Define a resource class and a single route, and you get index, show, new/create, edit/update, and destroy actions with search and pagination. Built on top of layered-ui-rails, Ransack, and Pagy.
Most Rails apps need an admin area, an internal dashboard, or a "list and edit some records" screen long before they need anything bespoke. layered-resource-rails gets you there in a few lines, then stays out of your way as your needs grow:
- Skip the boilerplate. Declare your columns, fields, and search - get index, show, forms, search, sort, and pagination for free. No scaffold to maintain, no half-finished admin gem to fight.
- Looks right out of the box. Tables, forms, and pagination come pre-styled via layered-ui-rails with WCAG 2.2 AA compliance and dark mode included.
- Override only what you need. Swap a single view partial, subclass the controller for a custom scope or redirect, or generate plain ERB to take full control - without rewriting the rest.
- Eject cleanly if you outgrow it. Generate a standard Rails controller and views, drop the gem, and you're left with idiomatic Rails. No lock-in, no hidden coupling.
An agent skill is included so AI coding agents can work with layered-resource-rails in your project. Once installed, the agent can handle the full setup - just ask it to add layered-resource-rails to your app and it will install the gem, scaffold resources, and wire up routes.
Project install - scoped to a single repo, available to all contributors:
bin/rails generate layered:resource:install_agent_skillGlobal install - available across all your projects:
./install-skill.shOr install remotely without cloning the repo:
curl -fsSL https://raw.githubusercontent.com/layered-ai-public/layered-resource-rails/main/install-skill.sh | sh- Ruby on Rails >= 8.0
- layered-ui-rails ~> 0.13
- Ransack ~> 4.0
- Pagy ~> 43.2
Add to your Gemfile and install:
bundle add layered-resource-railslayered-resource-rails depends on layered-ui-rails for its UI. If you haven't already set it up, run its install generator:
bin/rails generate layered:ui:installThe fastest path: scaffold the model, migration, resource, and route in one shot.
rails g layered:resource:scaffold post title:string body:text
This invokes Rails' built-in model generator (so you get the migration and model), writes app/layered_resources/post_resource.rb with columns and fields derived from the attributes, and appends layered_resources :posts to config/routes.rb. Views are intentionally not generated - the gem's defaults render until you eject them with rails g layered:resource:views posts.
Pass --skip-model if the model already exists. Restrict which CRUD actions get routed with --actions index show (emits only:) or --except destroy (emits except:). Pass --controller to also eject a controller and wire it into the route, or --views to eject the templates upfront.
If the model already exists and you just want the resource class plus its route, use rails g layered:resource post title:string body:text instead. It writes app/layered_resources/post_resource.rb and appends layered_resources :posts - pass --skip-route to skip the route line.
The rest of this section walks through what the generator produces.
Create a file at app/layered_resources/post_resource.rb:
class PostResource < Layered::Resource::Base
model Post
columns [
{ attribute: :title, primary: true },
{ attribute: :status },
{ attribute: :created_at, label: "Published" }
]
search_fields [:title]
default_sort attribute: :created_at, direction: :desc
fields [
{ attribute: :title },
{ attribute: :body, as: :text },
{ attribute: :status }
]
endIn config/routes.rb:
Rails.application.routes.draw do
layered_resources :posts
endThat's it. You now have a full CRUD interface with search and pagination for Post.
| Route | Action | Description |
|---|---|---|
GET /posts |
index | Paginated table |
GET /posts/:id |
show | Post detail page |
GET /posts/new |
new | New post form |
POST /posts |
create | Create post |
GET /posts/:id/edit |
edit | Edit post form |
PATCH /posts/:id |
update | Update post |
DELETE /posts/:id |
destroy | Delete post |
The index table's primary column (primary: true, or the first column) links to each record's edit page - or, for read-only resources without an edit action, to its show page.
Each action also sets @page_title for use by the layout's <title> tag - "Posts" on index, "New Post" on new, the record's primary column value on show, and "Edit <record label>" on edit. Override after super in a custom action if you need something different.
Read-only (no forms): omit fields and restrict routes:
class PostResource < Layered::Resource::Base
model Post
columns [
{ attribute: :title, primary: true },
{ attribute: :created_at, label: "Published" }
]
endlayered_resources :posts, only: [:index]Restrict actions with only: or except::
layered_resources :posts, only: [:index, :show, :edit, :update]
layered_resources :posts, except: [:destroy]The default show view is intentionally a blank canvas - the gem doesn't auto-render an attribute list, since generated detail pages tend to be low-value and always need customizing. The index links each record's title to its edit page, not its show page, so show is only worth keeping if you're going to build a real detail view (eject it with rails g layered:resource:views and fill in the template). If you're not, pass except: [:show] to drop the route.
Root breadcrumb: top-level resources render no breadcrumb trail by default. Declare a static first crumb — typically a link back to the host app's dashboard:
class PostResource < Layered::Resource::Base
model Post
root_breadcrumb "Home", "/"
endNested routes prepend it to the derived parent trail (e.g. Home / Users / Alice). Pass nil as the path to render unlinked text.
Custom scope (e.g. tenant isolation):
class PostResource < Layered::Resource::Base
model Post
# ...columns, fields, etc.
def self.scope(controller)
controller.current_team.posts
end
endFor the common case where every record belongs to the signed-in user (or
tenant), use the owned_by shorthand:
class QuoteResource < Layered::Resource::Base
model Quote
owned_by :user # via :current_user (default)
# owned_by :account, via: :current_account
endowned_by is purely behavioural - it expands to:
scope(controller)returnsQuote.where(user: controller.current_user), so the index, show, edit, etc. only see records the user owns.build_record(controller)assigns the owner on new records, socreatestampsuser_idautomatically.- When
controller.current_userisnil,owned_byraisesLayered::Resource::MissingOwnerErrorso a missingauthenticate_user!surfaces immediately instead of every page silently 404ing. Passallow_nil: truefor genuinely public-with-scope behaviour (returnsQuote.noneand assigns nil on create). Withuse_pundit, the policy gate (policy.create?) decides —owned_bylets nil through.
owned_by is a fact about the data, not a gate on what users can do. For
per-action authorisation ("can this user edit this record?") use
use_pundit or override scope yourself.
scope(controller) is the universal seam - any read filter (Pundit,
CanCan, a plain PORO) goes there. The gem also ships first-class Pundit
support via opt-in:
class PostResource < Layered::Resource::Base
model Post
use_pundit
endWhen enabled:
scope(controller)defaults to the controller'spolicy_scope(model)helper - the index reads throughPolicy::Scope#resolve. This routes through Pundit'spundit_user, so apps that authorize ascurrent_account(or any other identity) get the same context here as inauthorizecalls.- Every action that loads or builds a record (
new,create,show,edit,update,destroy, plus any custom member action declared in alayered_resourcesblock) callsauthorize(@record)automatically. Pundit raisesPundit::NotAuthorizedErroron denial; handle it in yourApplicationControlleras you would for any Pundit-backed app. - Action buttons hide automatically: the
Newlink on the index, theEdit/Deleteitems in the index row's actions popover, and theEdit/Deletebuttons on the show page checkpolicy(record).new?/update?/destroy?so users only see actions they can perform.
verify_authorized/verify_policy_scoped: if yourApplicationControllerruns Pundit'safter_action :verify_authorized(or:verify_policy_scoped) globally, layered resources that don't opt intouse_punditwill raiseAuthorizationNotPerformedbecause the controller never callsauthorizefor them. Either skip those checks for the layered controller (skip_after_action :verify_authorized, if: -> { is_a?(Layered::Resource::ResourcesController) }) or scope the hardening to the controllers you actually want it on.
owned_by composes with use_pundit: Pundit owns the read filter
(Policy::Scope wins over the owner-where), and owned_by still drives
owner assignment on create. Stack them when the policy needs to express
more than ownership:
class PostResource < Layered::Resource::Base
model Post
use_pundit
owned_by :user
endCanCan, plain POROs, and bespoke policies are still supported - just
override scope (and add per-action before_action :authorize_* callbacks
in an ejected controller). Use use_pundit when Pundit is the right fit
and you want the integration without writing it.
Two conventions worth knowing:
- Compare ids, not associations. Inside policy methods write
record.user_id == user.idrather thanrecord.user == user. The id comparison avoids loading the association and works on unsaved records. - The policy queried matches the route, not the model being mutated.
For a custom member action declared on
layered_resources :questions(e.g.POST /questions/:id/submit_answer),@recordis aQuestionand the gem callsQuestionPolicy#submit_answer?— even if the action ultimately creates anAnswer. Put the predicate on the policy for the resource the route lives on.
Inside ejected views, the resource_can?(action, record = nil) helper
returns true only when both the route exposes the action and (when
Pundit is enabled) the policy permits it for the given record. Use it
wherever you'd previously read @resource_can_*:
<% if resource_can?(:update, @record) %>
<%= link_to "Edit", edit_post_path(@record) %>
<% end %>The raw @resource_can_* ivars remain available for route-only checks
(no per-record policy lookup).
Resources are independent - each model gets its own resource class. To surface association data on an index, add a virtual column whose attribute: is a method on the model. For Post belongs_to :user, expose user.name by delegating on the model:
class Post < ApplicationRecord
belongs_to :user
delegate :name, to: :user, prefix: true, allow_nil: true # post.user_name
endclass PostResource < Layered::Resource::Base
model Post
columns [
{ attribute: :title, primary: true },
{ attribute: :user_name, label: "Author" },
{ attribute: :created_at, label: "Published" }
]
endsearch_fields accepts association-walking entries in Ransack's <association>_<attribute> form. An entry that isn't a column on the resource's own model but matches an association plus a column on the associated model (e.g. :user_name for belongs_to :user and users.name) makes the index search box join into the association:
class PostResource < Layered::Resource::Base
model Post
search_fields [:title, :body, :user_name] # user_name searches users.name
endThe Ransack allowlists are scoped to the resource: the association and the associated model's attribute are only ransackable when this resource is the one searching, so nothing else gains access to the associated model and any host-app Ransack config is preserved.
A walked search field is also sortable — q[s]=user_name asc orders the index by users.name — because Ransack derives its sort allowlist from the search allowlist. Associations not declared in search_fields stay unsearchable and unsortable.
The index search box's placeholder is derived from search_fields via human_attribute_name, so attribute renames declared in the standard Rails i18n location flow through automatically. Given search_fields [:title, :user_sid]:
# config/locales/en.yml
en:
activerecord:
attributes:
user:
sid: Identifierproduces "Search by title, user identifier". Association walks are labelled <association> <attribute>, each half resolved against its own model's human names (so translating post.user changes the "user" half too).
To replace the derived text wholesale, declare search_placeholder:
class PostResource < Layered::Resource::Base
model Post
search_fields [:title, :body, :user_sid]
search_placeholder "Search by title, body or author identifier"
endTo scope posts to a user (/users/:user_id/posts), nest the route inside a Rails resources :users do block or an explicit scope "users/:user_id". Either form produces the standard Rails nested-resources helper names (user_posts_path, user_post_path, new_user_post_path, …) — polymorphic_path([@user, :posts]) and link_to "Edit", [@user, @post] resolve to them with no extra wiring:
# config/routes.rb
layered_resources :users
resources :users, only: [] do
layered_resources :posts
end
# …or, equivalently…
scope "users/:user_id" do
layered_resources :posts
endDon't add
as:to a surroundingscope.layered_resourcesderives its own helper names from the path segments (scope path: "manage"→manage_posts_path,new_manage_post_path, …), so anas:is unnecessary — and a value that disagrees with the path is ignored with a warning.
Resolve the parent in the resource's scope:
class PostResource < Layered::Resource::Base
model Post
# ...columns, fields, etc.
def self.scope(controller)
if controller.params[:user_id].present?
User.find(controller.params[:user_id]).posts
else
Post.all
end
end
endA column on the parent can link to its children's index using link: with the nested route's key:
class UserResource < Layered::Resource::Base
model User
columns [
{ attribute: :name, primary: true },
{ attribute: :email },
{ attribute: :posts_count, label: "Posts", as: :badge, rounded: true, link: :user_posts }
]
endThe posts_count cell on each user row links to /users/:id/posts. link: wraps the column's normal rendering in a link, so it composes with as: — pair it with as: :badge if you want a badge link.
Use a counter cache for child counts, not a virtual child.size column. Render the count as a rounded badge (as: :badge, rounded: true) so it reads as a count, not a value. Point the column at a real counter-cache attribute and add counter_cache: true to the child's belongs_to:
class Post < ApplicationRecord
belongs_to :user, counter_cache: true # maintains users.posts_count
end# add_column :users, :posts_count, :integer, default: 0, null: falseA counter-cache column reads straight off the parent row, so the index renders in one query. A virtual column backed by user.posts.size issues a COUNT per row (an N+1) and can't be sorted or searched. Once posts_count is a real column it sorts via q[s]=posts_count asc like any other.
Where search_fields gives a single free-text box, filters adds structured controls for narrowing the index by specific attributes. The UI follows the "add filter" pattern: an Add filter button opens a popover listing the declared filters; picking one adds it as a tag with its controls popover already open, ready to take a value; pressing the tag's label reopens the popover, and its ✕ removes it. Booleans and single-choice selects apply instantly on click; multi-selects, ranges, and text filters have an Apply button.
Under the hood every filter is a Ransack predicate in the query string, so filters compose with search, sort, and pagination — all coexist in the URL and survive each other's submits (the search form and each filter form round-trip the other params as hidden fields; no JavaScript involved). The lightweight f[] param records which tags were added and in what order — new tags join the end of the row (after any pinned ones) and stay put when set; a tag's ✕ removes its entry.
Declare filters with a list of attributes. The control and predicate are inferred from each column:
class PostResource < Layered::Resource::Base
model Post
filters :status, # enum column -> multi-select of its values (status_in)
:featured, # boolean column -> Yes / No (featured_eq)
:created_at, # date/datetime -> from / to date range (created_at_gteq / _lteq)
:comments_count, # integer/decimal -> from / to number range (comments_count_gteq / _lteq)
:user # belongs_to -> multi-select of associated records (user_id_in)
end| Column type | Control | Ransack predicate |
|---|---|---|
enum |
multi-select of the enum's values | _in (or _eq with multiple: false) |
boolean |
Yes / No | _eq |
date / datetime |
from–to date range | _gteq + _lteq |
integer / decimal / float |
from–to number range | _gteq + _lteq |
belongs_to |
multi-select of associated records | <foreign_key>_in (or _eq) |
string / text |
text "contains" (multi-select if a collection: is given) |
_cont (or _in / _eq) |
Select-type filters default to multi-select: a checkbox list with an Apply button, filtering via the _in predicate. Pass multiple: false for a single-choice select — those render as a list of links that apply instantly on click (booleans always do).
A belongs_to filter keys on the foreign-key column (user_id), so it never joins — no association-walk setup is needed, unlike search_fields. By default its options are klass.all, labelled by the first present of name/title/label/email; pass a collection: to scope, order, or label differently. As with search, every filtered attribute is added to the resource's Ransack allowlist; attributes that are neither shown, searched, nor filtered stay un-queryable and any q[...] referencing them is silently ignored rather than raising.
Pass an options hash per attribute to override what's inferred:
filters :created_at,
status: { collection: %w[draft live] }, # override the select options
user: { multiple: false, # single-choice (user_id_eq) instead of multi
collection: -> { User.active } }, # scope the options per request
title: { as: :string, label: "Headline" } # force a "contains" text filterRecognised keys:
as:— force a control type (:select,:boolean,:string,:range,:date_range).collection:— options for a select: an array of values, an array of[label, value]pairs, or a callable resolved per request (returning either form, or records). Acollection:on a plain string column promotes it to a select.multiple:—true(the default for select-type filters) renders checkboxes applying via the_inpredicate;falserenders instant-apply single-choice links via_eq.label:— override the filter's name (defaults tohuman_attribute_name, so i18n flows through).pinned:— always show the tag. Pinned tags never appear in the add-filter menu and have no remove ✕ (their popover's Clear resets the value; the tag stays).default:— value applied when the request carries none of the filter's params: a scalar,{ from:, to: }for ranges, an array formultiple:, or a callable resolved per request (e.g.-> { { from: 7.days.ago.to_date } }).
filters :created_at,
status: { pinned: true, default: Post.statuses[:published] },
user: { pinned: true }Pinned filters render as tags from the start, so the common ones are one click away instead of two; the Add filter button only renders while there are unpinned filters left to add (pin everything and it disappears). A default: applies whenever the request carries no state for that filter — the tag shows it as active and every link and form round-trips it explicitly from then on. Clearing a defaulted filter writes an explicit blank (q[status_eq]=) rather than dropping the param, so the default doesn't immediately re-apply.
The filter bar renders inside the index's Turbo frame between the search box and the table; eject the views (rails g layered:resource:views) to customise placement — the bar is the _filters partial, and each control is _filter_control.
Each column on the index table is rendered through a partial. By default the gem picks one based on the model's column type (text for strings, datetime for timestamps, etc.), but you can pin a column to a specific renderer with as::
columns [
{ attribute: :title, primary: true },
{ attribute: :status, as: :badge, variants: { published: :success, draft: :warning } },
{ attribute: :priority, as: :badge, rounded: true },
{ attribute: :created_at, as: :datetime, format: "%Y-%m-%d" },
{ attribute: :pinned, as: :boolean, true_label: "Yes", false_label: "No" }
]The built-in column types are :text, :datetime, :badge, and :boolean. Lookup order is per-resource → host-wide → gem default, so any partial you place at app/views/layered/<resource>/columns/_<type>.html.erb overrides the gem's built-in for that resource only, and one at app/views/layered/resource/columns/_<type>.html.erb overrides it host-wide.
Use the column generator to eject a built-in or scaffold a new one:
rails g layered:resource:column badge # eject the built-in host-wide
rails g layered:resource:column badge posts # eject scoped to PostResource
rails g layered:resource:column priority_badge # scaffold a brand-new typeA custom partial receives record, value, and options (the column hash) as locals - read keys like :variants or :format straight off options.
To render an introduction above the search area on a resource's index page, drop a partial at app/views/layered/<resource>/_introduction.html.erb. It's rendered when present and skipped otherwise — no DSL or configuration needed.
<%# app/views/layered/posts/_introduction.html.erb %>
<div class="l-ui-mt-4">
<p>Browse the latest posts. Use search and sort to find what you're looking for.</p>
</div>The partial sits inside the resource's view directory, so it follows the same per-resource override path as ejected views and column partials.
By default each entry in fields is permitted as a scalar. To allow an array (e.g. has_many_attached) or a nested hash (e.g. accepts_nested_attributes_for), set permit: on the field:
fields [
{ attribute: :title },
{ attribute: :documents, as: :file, permit: [] }, # array of files
{ attribute: :address_attributes, permit: [:street, :city, :zip] } # nested hash
]permit: [] produces params.permit(documents: []); permit: [:street, :city] produces params.permit(address_attributes: [:street, :city]).
To add non-CRUD actions (e.g. POST /posts/:id/approve, POST /posts/bulk_archive), pass a block to layered_resources with the same member/collection DSL Rails' resources uses. A block requires controller: because the action implementation has to live somewhere - generate a controller subclass with rails g layered:resource:controller posts and point the route at it:
layered_resources :posts, controller: "posts" do
member do
post :approve
end
collection do
get :bulk_archive
post :bulk_destroy
end
endclass PostsController < Layered::Resource::ResourcesController
def approve
@record.update!(approved: true)
redirect_to layered_member_path(@record), notice: "Post approved"
end
def bulk_archive
# render a confirmation page, run the archive, etc.
end
endInside the controller, @resource is the resource class and the standard Rails before_actions from ApplicationController (e.g. authenticate_user!) still apply.
Auto-loaded @record (member actions only). Custom member actions get @record set from params[:id] via @resource.scope(self).find before the action runs. This does not extend to collection actions — those have no :id and @record stays nil, so do any lookups yourself. Opt a member action out of the auto-load with skip_before_action :load_layered_member_record, only: [:foo] if it shouldn't 404 on a missing record (or shouldn't pay for the lookup).
Path helpers available inside actions:
layered_collection_path— index path for the current resource. Raises if:indexisn't routed.layered_member_path(record)— show/update/destroy path for the current resource. Raises if no member route is registered.layered_routes.<helper>_path(...)— any registered route, with parent params filled in from the current request.
For variants that warrant their own URL - typically a separate admin area - declare a subclass and register it on its own route. The subclass inherits model, columns, fields, search_fields, search_placeholder, default_sort, per_page, and root_breadcrumb from the parent and overrides only what differs:
# app/layered_resources/admin/post_resource.rb
class Admin::PostResource < PostResource
columns [
{ attribute: :title, primary: true },
{ attribute: :status },
{ attribute: :author_name, label: "Author" },
{ attribute: :created_at, label: "Published" }
]
fields [
{ attribute: :title },
{ attribute: :body, as: :text },
{ attribute: :status },
{ attribute: :pinned, as: :checkbox }
]
end# config/routes.rb
layered_resources :posts
namespace :admin do
layered_resources :posts, resource: "Admin::PostResource"
endsearch_fields and model aren't redeclared - they're inherited from PostResource.
Layered::Resource::ResourcesController inherits from your app's ApplicationController, so any before_action you've declared there (e.g. Devise's authenticate_user!) already protects every layered resource request.
For an engine with its own ApplicationController (running its own authorize/authenticate chain), define a sibling controller and include the gem's concern:
# app/controllers/layered/assistant/resources_controller.rb
class Layered::Assistant::ResourcesController < Layered::Assistant::ApplicationController
include Layered::Resource::Controller
endThen pass namespace: so layered_resources derives both the resource class and the controller from one option:
# config/routes.rb (or your engine's routes)
scope path: "/assistant", module: "layered/assistant" do
layered_resources :skills, namespace: "Layered::Assistant"
endThis resolves to resource: "Layered::Assistant::SkillResource" and routes to Layered::Assistant::ResourcesController automatically — no per-route resource:/controller: plumbing.
namespace: is explicit-only. Avoid wrapping in a namespace :foo block: Rails composes URL helpers differently inside one (e.g. foo_new_post_path instead of new_foo_post_path), and the gem-shipped views call the latter form. Use scope path:/module: as above to get the path/module without the :as prefix.
Flash strings come from i18n. The gem ships English defaults under layered.resource.flash.*:
| Key | Trigger |
|---|---|
created |
create succeeded |
updated |
update succeeded |
deleted |
destroy succeeded |
not_deleted |
record.destroy returned false |
dependent_records |
destroy rescued ActiveRecord::InvalidForeignKey or ActiveRecord::DeleteRestrictionError (the gem catches these so dependent-record violations redirect with a flash instead of 500ing) |
Override per-locale by adding the same keys in your host app's config/locales/<lang>.yml:
en:
layered:
resource:
flash:
created: "%{model} added successfully"
dependent_records: "Can't delete %{model} — it still has linked records."The %{model} interpolation is model.model_name.human, so it picks up any activerecord.models.<key> translations you've already defined.
The default show view is a heading with Edit and Delete buttons - it
doesn't iterate over columns because columns are designed for table cells
and have no per-user gating. If a column is configured for the index it
would otherwise leak in full on show. When you want a real detail page,
eject views with rails g layered:resource:views <name> and write the show
template against @record directly.
The gem is designed so you can start fully managed and progressively take over control if you outgrow the defaults.
Override the scope or redirect target directly in the resource class:
class PostResource < Layered::Resource::Base
model Post
# ...columns, fields, etc.
def self.scope(controller)
controller.current_team.posts
end
def self.build_record(controller)
scope(controller).build(author: controller.current_user)
end
def self.after_save_path(controller, record)
controller.main_app.post_path(record)
end
endEject views when you need full control over presentation:
rails g layered:resource:views posts
This copies the gem's actual index, show, new, and edit templates into app/views/layered/posts/ - fully populated, working ERB you can edit immediately. Delete any of them to fall back to the gem default; keep the rest to override only what you need.
Override the controller. Use the generator to create one in the right place:
rails g layered:resource:controller posts
This gives you a controller that inherits from the base - override any of the standard CRUD actions and call super when you only want to tweak behaviour.
If you outgrow the gem entirely, drop the inheritance and write a plain Rails controller:
class PostsController < ApplicationController
def index
@posts = Post.all
end
# ...
end# swap the route
resources :postsRun the included dummy app locally to explore:
git clone https://github.com/layered-ai-public/layered-resource-rails.git
cd layered-resource-rails
bundle install
cd test/dummy && bin/rails db:setup && bin/devThis project is still in its early days. We welcome issues, feedback, and ideas - they genuinely help shape the direction of the project. That said, we're holding off on accepting pull requests for now to stay focused on getting the foundations right. Thank you for your patience and interest. See CLA.md for the full policy.
Released under the Apache 2.0 License.
Copyright 2026 LAYERED AI LIMITED (UK company number: 17056830). See NOTICE for attribution details.
The source code is fully open, but the layered.ai name, logo, and brand assets are trademarks of LAYERED AI LIMITED. The Apache 2.0 license does not grant rights to use the layered.ai branding. Forks and redistributions must use a distinct name. See TRADEMARK.md for the full policy.