layered-assistant-rails
An open source Rails 8+ engine built on layered-ui-rails that provides a multi-provider AI assistant with streaming responses and a full conversation UI.
Requirements
- Ruby >= 3.2.0
- Ruby on Rails >= 8.0
- layered-ui-rails installed in the host app
Agent skill
An agent skill is included so AI coding agents can work with layered-assistant-rails in your project. Once installed, the agent can handle the full setup - just ask it to add layered-assistant-rails to your app and it will install the gem, run the generator, and configure your layout.
Project install - scoped to a single repo, available to all contributors:
bin/rails generate layered:assistant:install_agent_skillGlobal install - available across all your projects:
./install-skill.sh
# or install remotely without cloning the repo:
curl -fsSL https://raw.githubusercontent.com/layered-ai-public/layered-assistant-rails/main/install-skill.sh | shInstallation
Add to your Gemfile:
gem "layered-assistant-rails"Then run:
bundle installSetup
Install generator
Run the install generator to register imports and mount the engine:
bin/rails generate layered:assistant:installThis will:
- Copy
layered_ui.csstoapp/assets/tailwind/ - Add
@import "./layered_ui";to yourapplication.css - Add
import "layered_ui"to yourapplication.js - Add
import "layered_assistant"to yourapp/javascript/application.js(after the layered-ui import) - Mount the engine at
/layered/assistantin yourconfig/routes.rb - Copy engine migrations into your application
All steps are idempotent - re-running the generator will not duplicate imports, routes, or migrations.
Upgrading
After updating the gem, copy any new migrations and run them:
bin/rails generate layered:assistant:migrations
bin/rails db:migrateAuthorization
All non-public engine routes are blocked by default (403 Forbidden) until you configure an authorize block. The install generator creates a starter initialiser at config/initializers/layered_assistant.rb - uncomment one of the examples to get started.
Once configured, visit /layered/assistant (or wherever you mounted the engine) to verify access.
Allow all requests
Layered::Assistant.authorize do
# No-op: all requests permitted
endRequire sign-in (Devise)
Layered::Assistant.authorize do
redirect_to main_app.new_user_session_path unless user_signed_in?
endRestrict to admins
Layered::Assistant.authorize do
head :forbidden unless current_user&.admin?
endThe block runs in controller context, so you have access to request, current_user, redirect_to, head, main_app, and all other controller methods.
Checking access in views
The l_assistant_accessible? helper evaluates the authorize block without side effects. Use it to conditionally show navigation or links to the engine:
<% if l_assistant_accessible? %>
<%= link_to "Assistant", layered_assistant.root_path %>
<% end %>Record scoping
Engine records are owned. Assistants, conversations, personas, providers and skills are stamped with an owner on create and filtered by it on read, so users only see their own records. The owner defaults to the signed-in user, so multi-tenant apps need no configuration.
Changing the ownership boundary
To scope records to something other than the signed-in user - their organisation, say - configure an owner block in the initialiser:
Layered::Assistant.owner do
current_user.organisation
endThe block runs in controller context. Whatever it returns is used as the owner for both reads and creates.
When there is no owner
Reads return no records, and create actions raise Layered::Assistant::MissingOwnerError rather than persisting a record that every scoped read would then hide. Make sure your authorize block only admits authenticated users.
If you configure an owner block, it must return a record for every request your authorize block admits - not just for signed-in users. current_user.organisation returns nil for someone who has not created an organisation yet, and that user will hit MissingOwnerError on their first create. Either give the block a fallback, or have your authorize block send those users somewhere to set one up first.
Ownership is enforced at the controller layer, not by model validations. Out-of-scope IDs return 404.
The conversation user
Ownership answers "which records may this request see". It does not answer "who is doing the talking" - and once an owner block scopes records to an organisation, the owner cannot: every member of that organisation shares it.
So a conversation separately records the signed-in user who started it:
conversation.owner # => #<Organisation id: 3> the boundary records are scoped to
conversation.user # => #<User id: 91> the person who askedThe two are the same record until you configure an owner block. user is
always whoever was signed in and is never redirected by a block, and it is nil
for an anonymous visitor on a public assistant. Tools read both - see
Tools.
Panel helpers
The engine provides two convenience helpers for wiring the layered-ui panel to the assistant. Use them inside content_for blocks in your application layout:
<% content_for :l_ui_panel_heading do %>
<%= layered_assistant_panel_header %>
<% end %>
<% content_for :l_ui_panel_body do %>
<%= layered_assistant_panel_body %>
<% end %>
<%= render template: "layouts/layered_ui/application" %>Both helpers accept keyword arguments that are forwarded as HTML attributes to the underlying turbo_frame_tag:
<%= layered_assistant_panel_body data: { controller: "panel" } %>| Helper | Description |
|---|---|
layered_assistant_panel_header |
Empty Turbo Frame (assistant_panel_header) populated by the engine's panel views |
layered_assistant_panel_body |
Turbo Frame (assistant_panel) that loads the conversation list from the engine's panel routes |
Tools
An assistant can call into the host application to fetch or change something
before it answers. Define a tool as a class in app/tools:
# app/tools/weather_tool.rb
class WeatherTool < Layered::Assistant::Tool
description "Get the current weather for a city."
argument :city, :string, required: true, description: "The city to look up."
argument :units, :string, description: "celsius or fahrenheit", enum: %w[celsius fahrenheit]
def call(city:, units: "celsius")
forecast = Weather.for(city)
{ city: city, temperature: forecast.temperature(units), summary: forecast.summary }
end
endThen list the tool classes in your initialiser:
Layered::Assistant.tools do
[ WeatherTool ]
endThe block is called per request rather than read once at boot, so tool classes reload in development like any other application class.
Giving tools to an assistant
Registering a tool makes it available to pick, not available to call. Each assistant is given its own set on its edit screen, and an assistant with no tools calls nothing - so adding a tool to the application does not hand it to every assistant at once. Two assistants can share a registered tool and still be given different sets:
| Assistant | Tools |
|---|---|
| Sales assistant | weather |
| Support assistant |
weather, order-lookup
|
The set is held by tool name rather than a foreign key, because tools are classes rather than records. A name whose class is no longer registered is ignored, so removing a tool from the initialiser does not break the assistants that listed it.
Outside the UI, assign the set with tool_names:
assistant.update!(tool_names: [ "weather", "order-lookup" ])Defining a tool
| Method | Description |
|---|---|
description |
What the tool does, in the model's words. This is the only thing the model has to go on, so be specific |
argument |
An argument the model may supply: argument :name, :type, required:, description:, enum:, items:
|
tool_name |
The name the model calls the tool by. Defaults to the class name without its Tool suffix, namespaces hyphenated: Weather::ForecastTool becomes weather-forecast
|
self.public = |
Whether the tool may be offered to a public assistant. false by default - see below |
permit |
A block deciding which conversations may call the tool - see Who may call a tool |
consent |
:always to put each call to the person talking before it runs. :never by default - see Asking before a tool runs |
Argument types are :string, :integer, :number, :boolean, :array and
:object. An :array takes items: to name its element type.
Subclass a tool to share logic and the declarations come with it: the child
inherits its parent's description, public flag, permit block, consent
setting and arguments, adds any arguments of its own, and may redeclare one by name to narrow it. The name is
the exception - the child derives its own from its class name, since two tools
answering to one name would collide in the registry.
#call receives the arguments as keywords and may return a string or anything
that responds to #to_json. Raising is safe: the error is reported back to the
model as the tool's result, so it can correct itself or explain, rather than
the response failing.
Inside #call, four methods give the calling context:
| Method | Description |
|---|---|
owner |
The record the conversation is scoped to. Scope the tool's reads and writes to this - it is the caller's boundary |
user |
The person doing the talking. The same record as owner until an owner block scopes ownership elsewhere, at which point owner is (say) the organisation and user is the member of it who asked |
conversation |
The conversation the call came from |
message |
The assistant message that asked for the call |
Registering a tool does not scope it. owner is handed to you, but nothing
enforces that you use it - a tool that queries across every tenant will do
exactly that. Scoping is the tool's own job:
def call(reference:)
owner.orders.find_by(reference: reference)
endWho may call a tool
Which assistants have a tool is a configuration decision; who may call it is a
runtime one. A permit block narrows a tool to the conversations that satisfy
it:
class RefundTool < Layered::Assistant::Tool
description "Refund an order."
permit { |conversation| conversation.user&.admin? }
endAn unpermitted tool is left out of the definitions sent to the provider, so the model is never told it exists, and is refused if it asks for it anyway from an earlier turn's history. The block is inherited like the other declarations, so a tool subclassed to share logic keeps its parent's policy unless it declares its own.
To apply one rule across every tool, configure an authorize_tool block in
your initialiser. It is given the tool class and the conversation, and
returning falsey withholds the tool:
Layered::Assistant.authorize_tool do |tool, conversation|
conversation.user&.permitted_tools&.include?(tool.tool_name)
endUnlike authorize, which guards the engine's routes, this is left open when
unconfigured: tool calls already sit behind that block and behind the set of
tools each assistant has been given. A block that raises denies the tool and
logs, rather than handing it over or failing the response.
Asking before a tool runs
Reads are usually fine unattended. Anything that writes, spends or sends is worth putting to the person talking first:
class RefundTool < Layered::Assistant::Tool
description "Refund an order."
consent :always
argument :reference, :string, required: true
def call(reference:)
owner.orders.find_by!(reference: reference).refund!
end
endThe call is recorded in the conversation with its arguments shown and nothing in it, and the response stops there - the composer stays disabled, and the call waits as long as it needs to, surviving a reload. Approving runs the tool and the response picks up where it left off. Declining reports the refusal to the model as the tool's result, so it can say something useful about being turned down rather than the conversation dead-ending. Stopping the response declines whatever is outstanding, including a call that has been approved but has yet to run - so a tool that writes, spends or sends does not slip through after the Stop. A call whose tool is already running is left to finish, its result recorded, and the response is still not picked back up.
Calls in the same batch that need no consent still run while one waits: each call stands on its own.
A tool that asks for consent needs both a user and an owner, so it is never offered to a public assistant - even one an anonymous visitor happens to be signed in for. There is nobody to ask an anonymous visitor, and a decision is recorded through the owner-scoped route a conversation with no owner cannot reach. The tool is withheld rather than offered and then stuck waiting.
Tools and public assistants
A conversation with a public assistant has no owner - it belongs to an anonymous visitor, so there is no boundary to scope a tool's reads to. Tools are private by default and withheld from those conversations even when the assistant has been given them. A tool that is safe to expose opts in, using the same word an assistant does:
class CurrentTimeTool < Layered::Assistant::Tool
description "Get the current date and time on the server."
self.public = true
def call
{ time: Time.current.iso8601 }
end
endThis is written as an attribute rather than a public true DSL because
public is Ruby's own method-visibility keyword. A class method of that name
would shadow it, and a tool whose body used a bare public to reopen
visibility would silently mark itself callable by anonymous visitors - too
sharp an edge for a flag that governs exposure.
The tool call loop
A response that asks for tools is not the end of the turn. The engine runs the
tools, records a message per result, then queues a fresh assistant message so
the model can answer with what came back - which may ask for tools again. The
composer stays disabled until a response completes without asking for
anything, and max_tool_cycles (default 10) bounds the loop.
Results are shown in the conversation as a collapsible panel naming the tool, with its input and output. A call waiting to be approved holds the loop where it is until it has been answered.
Configuration
Optional settings can be added to your initialiser (config/initializers/layered_assistant.rb):
# Log API errors to stdout (default: false)
Layered::Assistant.log_errors = true
# Total timeout in seconds for API requests, including the full streaming response (default: 210).
# Increase for models with high max_tokens limits or slow providers.
Layered::Assistant.api_request_timeout = 210
# Disable Active Record Encryption on Provider#secret.
# Only use this in development/test environments without encryption keys configured.
Layered::Assistant.skip_db_encryption = true
# How many rounds of tool calls one prompt may trigger before the engine gives
# up and says so (default: 10).
Layered::Assistant.max_tool_cycles = 10Note: skip_db_encryption is read at class load time, so it must be set before Layered::Assistant::Provider is first loaded. A standard Rails initialiser satisfies this requirement.
Demo
A dummy Rails app is included for development and testing:
cd test/dummy
bin/setup
bin/devThen visit http://localhost:3000.
Deploying the dummy app
The dummy app can be deployed with Kamal. Copy .env.deploy.example to .env.deploy and fill in the target, then deploy with bin/deploy:
cp .env.deploy.example .env.deploy
bin/deploy # kamal deploy
bin/deploy setup # first run on a new server
bin/deploy logs # any other kamal subcommand passes straight through.env.deploy is gitignored, so the server address stays out of the repo. The script reads it, checks the three KAMAL_* variables are set, and runs Kamal from test/dummy, where config/deploy.yml lives.
Testing
Run the gem tests from the root directory:
bundle exec rake testContributing
This 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.
License
Released under the Apache 2.0 License.
Copyright 2026 LAYERED AI LIMITED (UK company number: 17056830). See NOTICE for attribution details.
Trademarks
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.