--- URL: https://maquina.app/ Title: Open Source Tools for Rails Developers ---

Open Source Rails Tools

Tools for developers
who ship alone

Generators, UI components, and AI tools extracted from production Rails applications. No build pipelines. No framework fatigue. Built for the one-person framework philosophy.

Production-tested Rails tools

UI Library

Maquina Components

Modern UI components for Ruby on Rails. ERB partials styled with Tailwind CSS 4.0 and Stimulus controllers. Inspired by shadcn/ui, built for the Rails way.

AI Tools

Rails Claude Code

A marketplace of Claude Code plugins for Rails — code simplification, Rails upgrades, UI standards, MVP planning, Stimulus best practices, spec-driven development, security audits, and a Hotwire dev-server driver.

App Scaffolding

Maquina Generators

Rails generators that produce standalone application code. Authentication, job queues, error tracking, and security — no runtime dependency. Generate once, own forever.

Engine

Maquina Newsletters

A mountable Rails 8 engine for drafting, approving, scheduling, and batch-sending HTML newsletters. Action Text editing, an approval workflow, and background batch delivery.

Practical tools, not perfect abstractions

Every tool here earned its place in a shipping Rails app before it earned a name. No theory, no lock-in — patterns simple enough to own outright.

Production first
Every tool starts in a real application. No theoretical exercises, no “what if” features. If it's here, it's been shipped.
Standard Rails
ERB partials, Tailwind CSS, Stimulus only where needed. No new paradigms to learn — the Rails way, refined.
One-person scale
Built for developers who ship alone. Simple enough to understand, powerful enough to build real applications.

What we're building

equipr installing a skill into multiple coding agents from one command
August 10, 2026
equipr is out: one Go binary that installs skills, commands, and MCP servers into Claude Code, Codex, OpenCode, and Pi, with no plugin registration.
Mario Alberto Chávez Cárdenas
Mario Alberto Chávez Cárdenas
Rails MCP Server 2.0.0 — execute_ruby removed, introspection-only
August 4, 2026
Rails MCP Server 2.0.0 removes the execute_ruby tool entirely, because the reasoning that justified it in 2025 stopped holding once agentic coding tools could run Ruby themselves.
Mario Alberto Chávez Cárdenas
Mario Alberto Chávez Cárdenas
Fragua

Turn a feature into a shipped pull request — on a new Rails app or one you already have. Durable context, disciplined workflow, full observability from brief to branch.

Your host. Your keys. Your repo. Tokens bill straight to your Anthropic account — never proxied, never marked up.

Fragua workspace — AI agent orchestrator for Rails developers
Recuerd0

A dedicated knowledge base for managing the context your AI tools consume. Curate project knowledge once, serve it to every tool via REST API.

Works with Claude Code, Cursor, ChatGPT, and any tool that makes HTTP requests.

Recuerd0 — Versioned knowledge base for AI coding tools
Resto — Know your daily vibe, spend without the spiral
Resto

Know your daily vibe, spend without the spiral. Resto tells you exactly what you can spend today — no more "can I afford this?" anxiety. Kakeibo-inspired personal finance, simplified.

Track checking accounts, credit cards, and buffers. Reflect on your spending. Plan ahead. All in one place.

Need help with your Rails project?

I'm Mario Alberto Chávez—Rails architect available for consulting, architecture review, AI integration, and code review.

--- URL: https://maquina.app/documentation/tools/redis-menu/ Title: Redis Menu ---

A macOS menu bar application that makes managing local Redis instances simple and convenient. Start, stop, and configure Redis with a single click.

Redis Menu in the menu bar Redis Menu Preferences

Overview

Redis Menu sits in your macOS menu bar, providing easy access to start, stop, and configure Redis instances. Designed for developers who need to work with Redis locally and want a straightforward way to manage the database without terminal commands.


Features


Requirements


Installation

Build from Source

  1. Clone the repository:

    git clone https://github.com/maquina-app/redis-menu.git
    cd redis-menu
    
  2. Run the build script:

    ./build.sh
    

    The build script will:

  3. Move the built app to your Applications folder

Build Requirements


Usage

  1. Click the Redis icon in the menu bar to see status and control options
  2. Use “Start Redis” or “Stop Redis” to control the service
  3. Click “Preferences” to configure settings:

Configuration

Default Locations

Redis Menu stores data in these default locations:

Setting Default Path
Data directory ~/.local/share/redis/data
Log file ~/.local/state/redis/logs/redis.log
Port 6379

All locations can be customized in the app preferences.

Bundled Redis

Redis Menu downloads, compiles, and bundles Redis binaries for your Mac. You don’t need to install Redis separately. The app handles downloading and compiling the appropriate Redis source code for your system.


Troubleshooting

Redis Won’t Start

App Won’t Launch


Next Steps

GitHub Repository

Source code, issues, and contribution guidelines.

Mongo Menu

Similar menu bar app for managing MongoDB instances.

--- URL: https://maquina.app/documentation/ai-tools/rails-mcp-server/ Title: Rails MCP Server ---

A Ruby implementation of a Model Context Protocol (MCP) server for Rails projects. Let LLMs interact with your Rails codebase through code analysis, exploration, and development assistance.

Current Version: 2.0.0


What is MCP?

The Model Context Protocol (MCP) is a standardized way for AI models to interact with their environment. It defines a structured method for models to request and use tools, access resources, and maintain context during interactions.

Rails MCP Server implements the MCP specification to give AI models access to Rails projects for code analysis, exploration, and assistance.


Features


Quick Start

1. Install the Gem

gem install rails-mcp-server

2. Configure Projects

Option A: Interactive configuration

rails-mcp-config

This provides a TUI for managing projects, downloading guides, and configuring Claude Desktop.

Option B: Single-project mode (v1.5.0+)

For quick usage with the current directory:

cd /path/to/your/rails/app
rails-mcp-server --single-project

Option C: Environment variable (v1.5.0+)

export RAILS_MCP_PROJECT_PATH=/path/to/your/rails/app
rails-mcp-server

3. Configure Your AI Client

Claude Desktop

Select “Claude Desktop integration” in the configuration tool, or manually add to claude_desktop_config.json:

{
  "mcpServers": {
    "railsMcpServer": {
      "command": "ruby",
      "args": ["/path/to/rails-mcp-server/exe/rails-mcp-server"]
    }
  }
}

GitHub Copilot Agent (v1.5.0+)

See the Copilot Agent Setup Guide for detailed instructions.


Project Detection (v1.5.0+)

The server uses priority-based project detection:

Priority Method Description
1 (Highest) RAILS_MCP_PROJECT_PATH env var Explicit path to project
2 --single-project flag Uses current working directory
3 Auto-detection Detects Rails apps (Gemfile) or engines (gemspec)
4 (Lowest) projects.yml Traditional multi-project configuration

When only one project is configured, the server auto-switches to it.


Available Tools

The server provides 3 registered tools plus internal analyzers accessible via execute_tool.

Removed in v2.0.0: the execute_ruby tool has been removed. The server is now introspection-only — use the dedicated analyzers below (e.g. get_file, list_files, get_routes, get_schema). See Migrating from execute_ruby.

Registered Tools

Tool Description
switch_project Change the active Rails project
search_tools Discover available tools by category or keyword
execute_tool Invoke internal analyzers by name

Internal Analyzers

Analyzer Description
project_info Project information, Rails version, directory structure
list_files List files matching a pattern
get_file Retrieve file content
get_routes Rails routes with filtering
analyze_models Active Record models with associations and validations
get_schema Database schema information
analyze_controller_views Controller-view relationships
analyze_environment_config Environment configuration analysis
load_guide Load documentation guides

Usage Examples

Switch Project

switch_project(project_name: "my_rails_app")

Get Routes

execute_tool(tool_name: "get_routes")
execute_tool(tool_name: "get_routes", params: { controller: "users" })
execute_tool(tool_name: "get_routes", params: { verb: "POST" })

Analyze Models

execute_tool(tool_name: "analyze_models")
execute_tool(tool_name: "analyze_models", params: { model_name: "User" })
execute_tool(tool_name: "analyze_models", params: { model_name: "User", analysis_type: "full" })

Tips:

Get Schema

execute_tool(tool_name: "get_schema")
execute_tool(tool_name: "get_schema", params: { table_name: "users" })
execute_tool(tool_name: "get_schema", params: { detail_level: "tables" })

Tips:

Read a File

execute_tool(tool_name: "get_file", params: { path: "Gemfile" })
execute_tool(tool_name: "get_file", params: { path: "app/models/user.rb" })

Paths are relative to the project root. Reads are confined to the project directory, and sensitive files (.env, credentials, keys) are refused.

Find Files

execute_tool(tool_name: "list_files", params: { pattern: "app/models/**/*.rb" })
execute_tool(tool_name: "list_files", params: { pattern: "app/**/*user*" })

Migrating from execute_ruby

The execute_ruby tool was removed in v2.0.0. It ran caller-supplied Ruby via bin/rails runner, which made it an arbitrary-code-execution surface a pattern-based sandbox could not safely contain. The server is an introspection tool, and its dedicated analyzers cover what execute_ruby was used for:

Old execute_ruby usage Use instead
read_file(path) execute_tool(tool_name: "get_file", params: { path: … })
list_files(pattern) execute_tool(tool_name: "list_files", params: { pattern: … })
file_exists? / project_root list_files / execute_tool(tool_name: "project_info")
Routes / schema / models / controllers get_routes, get_schema, analyze_models, analyze_controller_views

Ad-hoc live data queries (User.count, custom scopes) are intentionally no longer supported. If you rely on free-form execution, pin to the 1.6.x line, which retains the hardened execute_ruby.


Server Modes

STDIO Mode (Default)

For direct integration with Claude Desktop:

rails-mcp-server

Single-Project Mode (v1.5.0+)

For working with the current directory only:

cd /path/to/rails/app
rails-mcp-server --single-project

HTTP Mode

For HTTP endpoints with JSON-RPC and SSE:

rails-mcp-server --mode http
rails-mcp-server --mode http -p 8080
rails-mcp-server --mode http --bind-all  # Allow LAN access

Endpoints:


Configuration

Environment Variable (v1.5.0+)

Set the project path explicitly:

export RAILS_MCP_PROJECT_PATH=~/projects/my-rails-app
rails-mcp-server

Manual Project Configuration

Edit ~/.config/rails-mcp/projects.yml:

store: "~/projects/store"
blog: "~/projects/rails-blog"
ecommerce: "/full/path/to/ecommerce-app"

Ruby Version Manager Users

Claude Desktop bypasses version manager initialization. Use the Ruby shim path:

{
  "mcpServers": {
    "railsMcpServer": {
      "command": "/home/your_user/.rbenv/shims/ruby",
      "args": ["/path/to/rails-mcp-server/exe/rails-mcp-server"]
    }
  }
}

The rails-mcp-config tool detects this automatically.


Documentation Resources

Access comprehensive documentation through load_guide:

execute_tool(tool_name: "load_guide", params: { library: "rails" })
execute_tool(tool_name: "load_guide", params: { library: "rails", guide: "getting_started" })
execute_tool(tool_name: "load_guide", params: { library: "rails", guide: "active_record_basics" })
execute_tool(tool_name: "load_guide", params: { library: "turbo" })
execute_tool(tool_name: "load_guide", params: { library: "stimulus" })
execute_tool(tool_name: "load_guide", params: { library: "kamal" })
execute_tool(tool_name: "load_guide", params: { library: "custom" })

Available libraries:

Library Content
rails Official Rails Guides
turbo Hotwire Turbo handbook and reference
stimulus Stimulus handbook and reference
kamal Kamal deployment documentation
custom User-added custom guides

Download guides using the configuration tool:

rails-mcp-config
# Select "Download guides"

Breaking Change in v1.5.0: The guides parameter was renamed to library.


Analyzer Parameter Reference

Analyzer Required Optional Parameters
project_info - max_depth, include_files, detail_level
list_files - directory, pattern
get_file path -
get_routes - controller, verb, path_contains, named_only, detail_level
analyze_models - model_name, model_names, detail_level, analysis_type
get_schema - table_name, table_names, detail_level
analyze_controller_views - controller_name, detail_level, analysis_type
analyze_environment_config - (none)
load_guide library guide

Common Parameter Values

detail_level:

analysis_type (for models and controllers):


Using with MCP Proxy

For STDIO-only clients that need HTTP/SSE capabilities:

# Start server in HTTP mode
rails-mcp-server --mode http

# Install and run MCP proxy
npm install -g mcp-remote
npx mcp-remote http://localhost:6029/mcp/sse

Configure Claude Desktop to use the proxy:

{
  "mcpServers": {
    "railsMcpServer": {
      "command": "npx",
      "args": ["mcp-remote", "http://localhost:6029/mcp/sse"]
    }
  }
}

Testing and Debugging

Use MCP Inspector to test the server:

npm -g install @modelcontextprotocol/inspector
npx @modelcontextprotocol/inspector /path/to/rails-mcp-server

The Inspector UI lets you:


Security

Introspection-only (v2.0.0+)

The server does not execute caller-supplied Ruby. It exposes a fixed set of introspection analyzers, so there is no arbitrary-code-execution surface. (The free-form execute_ruby tool was removed in v2.0.0.)

The tools that boot the app (get_schema, get_routes, and the introspection half of analyze_models / analyze_controller_views) run bin/rails runner with fixed, server-authored scripts; caller input is passed as validated parameters, never interpolated as code. Booting a project runs that project’s environment, so point the server only at Rails projects you trust.

Input Validation

All file-accessing tools use centralized input validation (PathValidator):


Compatibility

Component Supported Versions
Ruby 3.3+ (Ruby 3.2 dropped in v1.6.0)
Rails (target projects) 6.0+
Rails 8.1.1+ Full support (v1.5.0+)
Claude Desktop Supported
GitHub Copilot Agent Supported (v1.5.0+)
Other MCP Clients Via STDIO or HTTP mode

Changelog Highlights

v2.0.0

Breaking Changes:

Security:

v1.6.x

Security:

Breaking Changes:

v1.5.0

New Features:

Security:

Breaking Changes:


Next Steps

GitHub Repository

Source code, issues, and contribution guidelines.

AI Agent Guide

Comprehensive guide for AI agents using this server.

GitHub Copilot Setup

Configure Rails MCP Server with GitHub Copilot Agent.

RubyGems

Install the latest version from RubyGems.

--- URL: https://maquina.app/documentation/ai-tools/rails-security-auditor/ Title: Rails Security Auditor ---

A Claude Code plugin that audits a Rails application’s security configuration and produces a severity-grouped, actionable report grounded in Rails 8.0–8.2 defaults. Optionally applies fixes for any finding.


What Is This?

A Claude Code agent that:

The agent runs autonomously: it reads the files it needs, produces the full report, and pauses to offer fixes.


Quick Start

1. Add the Marketplace

/plugin marketplace add maquina-app/rails-claude-code

2. Install the Plugin

/plugin install rails-security-auditor@maquina

3. Run the Audit

> /audit-security

Or simply ask:

> Audit my Rails app's security configuration
> Check if my CSRF setup is correct
> Am I missing any security headers?

What It Checks

Ten check categories, drawn from the full catalog in references/checks.md:

Category Scope
PROD force_ssl, assume_ssl, log level, filter_parameters, trusted proxies
CSRF protect_from_forgery, strategy, per-form tokens
HDR X-Frame-Options, X-Content-Type-Options, X-XSS-Protection, Referrer-Policy
CSP Content Security Policy initializer and directives
SESS Session cookie SameSite, expire_after, httponly
RATE rate_limit macro, Rack::Attack throttles and safelists
AUTH Authorization gem, tenant scoping, allow_unauthenticated_access
GEM Brakeman, bundler-audit, Rails CVEs
CI Security scanning in CI pipeline
FWKD Rails 8.2 framework defaults (CSRF header strategy, transaction-aware jobs)

Plus: column-level encryption (encrypts) and hardcoded secrets scans.


How Findings Are Reported

Each finding follows a consistent structure:

### [PROD-01] force_ssl missing in production
File: config/environments/production.rb
Found: config.force_ssl is not set

Why this matters:
Without force_ssl, users on HTTP don't get redirected to HTTPS.
Cookies and sessions can travel in the clear on public networks.

How to fix it:
config.force_ssl = true

Offer: Would you like me to apply this fix?

Severity counts go at the top of the report:

Severity Meaning
❌ Critical Active vulnerability — fix immediately
⚠️ High Important gap — fix soon
🔶 Medium Hardening — recommended
ℹ️ Informational Optional improvement
✅ Passed Already configured correctly

Audit Principles

The auditor is designed to minimize noise:


Applying Fixes

After the report, you can fix findings one at a time, all Critical findings at once, or everything:

> Fix all Critical findings
> Apply the CSP fix only
> Create the missing rack_attack.rb initializer

For each fix the agent reads the current file, applies the minimal change needed, shows a before/after diff, and confirms the write. When a fix requires creating a new initializer from scratch, it generates the full file.


Package Contents

rails-security-auditor/
├── agents/rails-security-auditor.md    # Main agent
└── references/
    └── checks.md                       # Full check catalog with fixes

Team Installation

Add to your project’s .claude/settings.json:

{
  "extraKnownMarketplaces": {
    "maquina": {
      "source": {
        "source": "github",
        "repo": "maquina-app/rails-claude-code"
      }
    }
  },
  "enabledPlugins": [
    "rails-security-auditor@maquina"
  ]
}

Wire /audit-security into your release checklist so every major deploy starts with a clean report.


Next Steps

GitHub Repository

View source code and contribute.

Rails Upgrade Assistant

Pair with upgrades to pick up new security defaults.

Rails Simplifier

Clean up code patterns discovered during an audit.

--- URL: https://maquina.app/documentation/ai-tools/rails-simplifier/ Title: Rails Simplifier ---

A Claude Code plugin that refines Ruby on Rails code following 37signals patterns and the One Person Framework philosophy. Transform complex code into clean, maintainable Rails conventions.


What Is This?

A Claude Code skill that:


Philosophy

The One Person Framework

From DHH (December 2021):

“A toolkit so powerful that it allows a single individual to create modern applications upon which they might build a competitive business.”

Conceptual Compression

From RailsConf 2018:

“Like a video codec that throws away irrelevant details such that you might download the film in real-time.”

Vanilla Rails is Plenty

From Jorge Manrubia at 37signals:

“If you have the luxury of starting a new Rails app today, go vanilla.”


Quick Start

1. Add the Marketplace

/plugin marketplace add maquina-app/rails-claude-code

2. Install the Plugin

/plugin install rails-simplifier@maquina

3. Start Simplifying

> Review recent changes using the rails-simplifier skill

What It Simplifies

Pattern Simplification
Service objects Rich model methods + concerns
Custom controller actions CRUD resources
Boolean state columns State records (has_one :closure)
Fat controllers Thin controllers, model methods
Time.now Time.current
Hardcoded strings I18n keys
N+1 queries includes / preload
Date tests without travel_to Freeze time to fixture

Usage Examples

Review Recent Changes

> Review recent changes using the rails-simplifier skill

The skill analyzes your recent commits and suggests simplifications based on 37signals patterns.

Review a Specific Controller

> Use rails-simplifier to review the bookings controller

Review a Model

> Use rails-simplifier to review the Order model

Full Project Review

> Run rails-simplifier on the app directory

Simplification Patterns

Service Objects to Model Methods

Before:

# app/services/order_processor.rb
class OrderProcessor
  def initialize(order)
    @order = order
  end

  def process
    @order.update(processed_at: Time.current)
    @order.line_items.each(&:fulfill)
    OrderMailer.confirmation(@order).deliver_later
  end
end

# In controller
OrderProcessor.new(@order).process

After:

# app/models/order.rb
class Order < ApplicationRecord
  def process!
    update(processed_at: Time.current)
    line_items.each(&:fulfill)
    OrderMailer.confirmation(self).deliver_later
  end
end

# In controller
@order.process!

Boolean States to State Records

Before:

class Post < ApplicationRecord
  scope :published, -> { where(published: true) }
  scope :draft, -> { where(published: false) }
end

After:

class Post < ApplicationRecord
  has_one :publication

  scope :published, -> { joins(:publication) }
  scope :draft, -> { where.missing(:publication) }

  def publish!
    create_publication!
  end

  def unpublish!
    publication&.destroy
  end
end

Custom Actions to CRUD

Before:

# config/routes.rb
resources :posts do
  member do
    post :publish
    post :unpublish
    post :archive
  end
end

# app/controllers/posts_controller.rb
def publish
  @post.update(published: true)
  redirect_to @post
end

After:

# config/routes.rb
resources :posts do
  resource :publication, only: [:create, :destroy]
  resource :archival, only: [:create, :destroy]
end

# app/controllers/publications_controller.rb
class PublicationsController < ApplicationController
  def create
    @post = Post.find(params[:post_id])
    @post.create_publication!
    redirect_to @post
  end

  def destroy
    @post = Post.find(params[:post_id])
    @post.publication.destroy
    redirect_to @post
  end
end

N+1 Query Detection

Before:

def index
  @posts = Post.all
end

# In view: @posts.each { |post| post.author.name }

After:

def index
  @posts = Post.includes(:author)
end

Team Installation

Add to your project’s .claude/settings.json:

{
  "extraKnownMarketplaces": {
    "maquina": {
      "source": {
        "source": "github",
        "repo": "maquina-app/rails-claude-code"
      }
    }
  },
  "enabledPlugins": [
    "rails-simplifier@maquina"
  ]
}

Resources


Next Steps

GitHub Repository

View source code and contribute.

Rails MCP Server

Enhance analysis with MCP tools.

--- URL: https://maquina.app/documentation/ai-tools/rails-upgrade-skill/ Title: Rails Upgrade Skill ---

A comprehensive Claude skill that helps you upgrade Ruby on Rails applications through any version from 6.0 to 8.1.1. Built on official Rails CHANGELOGs. It analyzes your project with Claude Code’s built-in file tools — no external services required.


What Is This?

A Claude skill that:


Quick Start

1. Install the Plugin

/plugin marketplace add maquina-app/rails-claude-code
/plugin install rails-upgrade-assistant@maquina

2. Start Upgrading

Say to Claude:

"Upgrade my Rails app to 8.1"

Claude will:

  1. Detect your current version from Gemfile.lock and plan the path (single or multi-hop)
  2. Generate a breaking-changes detection script and run it
  3. Evaluate the findings against your actual code
  4. Produce a comprehensive upgrade report with OLD → NEW examples
  5. Offer to apply the fixes directly

Supported Upgrade Paths

From To Hops Breaking Changes Difficulty
8.0.x 8.1.1 1 8 changes Easy
7.2.x 8.0.4 1 13 changes Hard
7.1.x 7.2.3 1 38 changes Medium
7.0.x 7.1.6 1 12 changes Medium
6.1.x 7.0.0 1 17 changes Hard
6.0.x 6.1.0 1 18 changes Medium
6.0.x 8.1.1 6 106 changes Very Hard

Sequential Upgrades Required

Rails upgrades must be sequential:

Correct: 6.0 → 6.1 → 7.0 → 7.1 → 7.2 → 8.0 → 8.1
Wrong:   6.0 → 7.0 (skips 6.1)

For multi-hop upgrades, Claude will:


How It Works

Full upgrade (the default)

Best for understanding what needs to change before making edits.

"Upgrade my Rails app from 7.2 to 8.0"

Claude will:

  1. Read Gemfile.lock to detect the current version
  2. Load appropriate version guide(s)
  3. Analyze your project files for custom code
  4. Identify breaking changes affecting your code
  5. Generate comprehensive upgrade report

You remain in control and apply changes manually.

Query-specific

Best for specific questions about changes.

"What ActiveRecord changes are in Rails 8.0?"
"How do I handle the SSL configuration change?"
"What breaking changes affect my models?"
"Will my Redis cache work after upgrading to 8.0?"

Key Breaking Changes by Version

Rails 8.0 → 8.1

High impact:

Rails 7.2 → 8.0

High impact:

Rails 7.1 → 7.2

High impact:

Rails 7.0 → 7.1

High impact:

Rails 6.1 → 7.0

High impact:

Rails 6.0 → 6.1

High impact:


Custom Code Detection

The skill automatically detects and warns about customizations:

Database Configuration

# Custom SQLite path detected in config/database.yml
# Current: database: db/development.sqlite3
# Rails 7.1+: database: storage/development.sqlite3
# Action: Review and update path

SSL Middleware

# Custom SSL middleware detected in config/application.rb
# Line 23: middleware.use CustomSSLMiddleware
# Rails 7.1+: May conflict with config.force_ssl = true
# Action: Review compatibility

Autoload Paths

# Custom autoload_paths in config/application.rb
# Line 15: config.autoload_paths << Rails.root.join('lib')
# Rails 7.1+: lib/ autoloaded by default (config.autoload_lib)
# Action: Remove manual path to avoid conflicts

Asset Pipeline

# Custom Sprockets processors detected
# Files: lib/assets/processors/custom_minifier.rb
# Rails 8.0+: Propshaft doesn't support processors
# Action: Migrate to different approach or keep Sprockets

What You Get

Every upgrade request generates a detailed report:

1. Executive Summary

2. Project Analysis

3. Breaking Changes (Prioritized)

4. Code Examples (OLD vs NEW)

# OLD (Rails 7.2)
config.action_dispatch.show_exceptions = true

# NEW (Rails 7.2+)
config.action_dispatch.show_exceptions = :all

5. Step-by-Step Migration Guide

6. Testing Checklist


Pre-Upgrade Checklist

Before starting any upgrade:

Critical:

Important:


Package Contents

rails-upgrade-assistant/
├── agents/rails-upgrade-assistant.md   Main agent
├── workflows/                  How to generate deliverables
├── examples/                   Real usage scenarios
├── reference/                  Quick reference
├── version-guides/             Rails version details
├── templates/                  Report templates
└── detection-scripts/          Pattern definitions

Version Guides


Usage Examples

Simple Upgrade

"Upgrade my Rails app to 8.1"

With Specific Details

"Upgrade my Rails app from 7.2 to 8.0"

Risk Assessment Only

"Assess upgrade impact from 7.2 to 8.0"

Component-Specific Questions

"What ActiveRecord changes are in Rails 8.0?"
"Show me all configuration file changes for 7.2"

Next Steps

GitHub Repository

View source code and contribute.

Rails MCP Server

Optional — static code analysis to complement upgrades.

--- URL: https://maquina.app/documentation/ai-tools/spec-driven-development/ Title: Spec-Driven Development ---

A Claude Code plugin for Rails-focused spec-driven development. Shape a feature into a structured spec, break it into tasks, and hand it off to Claude Code for implementation — with a progress file that survives across sessions.


What Is This?

A Claude Code skill that turns rough feature ideas into implementation-ready specs:

The goal: features built to spec instead of code thrown together, with a paper trail you can point Claude at later.


The Workflow

Step Command Output
1. Initialize /sdd-init sdd/ directory + progress.yml
2. Plan the product /sdd-plan mission.md, roadmap.md, tech-stack.md
3. Shape a feature /sdd-shape planning/requirements.md + formal spec.md
4. Break into tasks /sdd-tasks tasks.md with ordered task groups
5. Discover standards /sdd-discover-standards Extracted patterns in standards/
6. Check progress /sdd-status Current phase, completed items, next step

Steps 1–2 are one-time setup. Steps 3–6 repeat per feature. You can also drive the workflow with natural language — the skill responds to “shape a spec for comments” or “what’s next?” without requiring the slash commands.


Quick Start

1. Add the Marketplace

/plugin marketplace add maquina-app/rails-claude-code

2. Install the Plugin

/plugin install spec-driven-development@maquina

3. Initialize in Your Project

> /sdd-init

The skill creates the sdd/ directory, a progress file, and the folder structure the rest of the workflow depends on.

4. Plan the Product

> /sdd-plan

If MVP Creator documentation already exists in the project, the skill reuses it. Otherwise it asks you the product-planning questions directly.

5. Shape Your First Feature

> /sdd-shape user authentication

The skill asks the clarifying questions, writes requirements, and produces a spec ready for task breakdown.


Slash Commands

Command What It Does
/sdd-init Bootstrap the sdd/ directory and progress.yml for a project
/sdd-plan Create mission, roadmap, and tech-stack documents from MVP docs or discovery
/sdd-shape Shape a feature into requirements and a formal spec
/sdd-tasks Generate an ordered task breakdown from a spec
/sdd-status Show current phase, completed steps, and next action
/sdd-discover-standards Extract coding patterns and tribal knowledge from the codebase

What You’ll Get

Each feature lives in its own folder under sdd/specs/:

sdd/
├── progress.yml                  # Workflow state across sessions
├── product/
│   ├── mission.md                # Product vision
│   ├── roadmap.md                # Feature priorities
│   └── tech-stack.md             # Technology choices
├── standards/                    # Coding standards discovered in your code
│   ├── global/
│   ├── backend/
│   └── frontend/
└── specs/
    └── 2026-04-16-user-auth/
        ├── planning/
        │   ├── requirements.md   # Gathered requirements
        │   └── visuals/          # Mockups, wireframes (optional)
        ├── spec.md               # Formal specification
        └── tasks.md              # Ordered task breakdown

Specs are self-contained: once shaped, Claude can execute them without needing to replay the conversation that produced them.


Usage Examples

Start From an Existing MVP

> /sdd-plan

If you ran MVP Creator earlier, the skill pulls directly from business-plan.md and technical-guide.md instead of asking the same questions again.

Shape a Feature From an Idea

> I want to add comments to posts

The skill asks for scope, user roles, moderation rules, and related code — then writes requirements and a spec.

Break a Spec Into Tasks

> /sdd-tasks for the user-auth spec

Produces grouped tasks (database layer, models, controllers, views, tests) with acceptance criteria.

Resume After a Break

> /sdd-status

Reads progress.yml and reports exactly where you left off — no context-replay needed.

Capture Tribal Knowledge

> /sdd-discover-standards

The skill scans your codebase for recurring patterns (naming conventions, controller structure, test style) and writes them into sdd/standards/ so future specs align with your existing code.


Package Contents

spec-driven-development/
├── README.md                            # Full documentation
├── QUICKSTART.md                        # Quick reference
├── commands/                            # 6 slash commands
│   ├── sdd-init.md
│   ├── sdd-plan.md
│   ├── sdd-shape.md
│   ├── sdd-tasks.md
│   ├── sdd-status.md
│   └── sdd-discover-standards.md
├── scripts/
│   ├── init_sdd.sh                      # Bootstrap SDD structure
│   ├── new_spec.sh                      # Create a new spec folder
│   └── status.sh                        # Show progress
├── templates/
│   ├── standard-template.md             # Spec template
│   └── progress.yml                     # Progress-tracking file
└── skills/spec-driven-development/
    ├── SKILL.md                         # Main skill (routes to the slash commands)
    └── references/
        ├── rails-standards.md           # Rails conventions
        ├── hotwire-patterns.md          # Turbo/Stimulus patterns
        └── document-templates.md        # Spec templates

Team Installation

Add to your project’s .claude/settings.json:

{
  "extraKnownMarketplaces": {
    "maquina": {
      "source": {
        "source": "github",
        "repo": "maquina-app/rails-claude-code"
      }
    }
  },
  "enabledPlugins": [
    "spec-driven-development@maquina"
  ]
}

Commit sdd/ alongside your code. Every teammate — human or AI — picks up the same plan, specs, and standards.


Next Steps

GitHub Repository

View source code and contribute.

MVP Creator

Upstream handoff: go from idea to MVP documentation.

Maquina UI Standards

Build the views described in your specs with consistent components.

--- URL: https://maquina.app/documentation/components/alert/ Title: Alert ---

Usage

<%= render "components/alert", icon: :info do %>
  <%= render "components/alert/title", text: "Heads up!" %>
  <%= render "components/alert/description", text: "You can add components using the CLI." %>
<% end %>

Examples

Destructive

<%= render "components/alert", variant: :destructive, icon: :triangle_alert do %>
  <%= render "components/alert/title", text: "Error" %>
  <%= render "components/alert/description", text: "Your session has expired." %>
<% end %>

Success

<%= render "components/alert", variant: :success, icon: :check_circle do %>
  <%= render "components/alert/title", text: "Success" %>
  <%= render "components/alert/description", text: "Your changes have been saved." %>
<% end %>

Info

<%= render "components/alert", variant: :info, icon: :info do %>
  <%= render "components/alert/title", text: "Heads up" %>
  <%= render "components/alert/description", text: "This release normalizes the default radius." %>
<% end %>

Warning

<%= render "components/alert", variant: :warning, icon: :triangle_alert do %>
  <%= render "components/alert/title", text: "Warning" %>
  <%= render "components/alert/description", text: "This action cannot be undone." %>
<% end %>

API Reference

Alert

Parameter Type Default Description
variant Symbol :default :default, :destructive, :success, :warning, :info; :error is accepted as an alias of :destructive
icon Symbol nil Icon name to display
css_classes String "" Additional CSS classes
html_options Hash {} Additional HTML attributes

Custom icon markup

icon: renders a built-in glyph as the alert's first child, which is what the variant icon colors key off. If you need your own markup instead — an inline SVG, an icon font, an <img>, or an icon that is not the first child — mark it with data-alert-part="icon" and it picks up the same sizing and per-variant color:

<%= render "components/alert", variant: :success do %>
  <span data-alert-part="icon"><%= image_tag "check.svg" %></span>
  <%= render "components/alert/title", text: "Saved" %>
<% end %>

Pass data: { has_icon: true } alongside it so the alert reserves the left padding it normally adds for icon:.

Alert Title

Parameter Type Default Description
text String nil Title text
content String nil HTML content via capture, or use block
css_classes String "" Additional CSS classes
html_options Hash {} Additional HTML attributes

Alert Description

Parameter Type Default Description
text String nil Description text
content String nil HTML content via capture, or use block
css_classes String "" Additional CSS classes
html_options Hash {} Additional HTML attributes
--- URL: https://maquina.app/documentation/components/badge/ Title: Badge ---

Usage

<%= render "components/badge" do %>
  Badge
<% end %>

Examples

Variants

<%= render "components/badge", variant: :primary do %>Primary<% end %>
<%= render "components/badge", variant: :secondary do %>Secondary<% end %>
<%= render "components/badge", variant: :destructive do %>Destructive<% end %>
<%= render "components/badge", variant: :success do %>Success<% end %>
<%= render "components/badge", variant: :warning do %>Warning<% end %>
<%= render "components/badge", variant: :info do %>Info<% end %>
<%= render "components/badge", variant: :outline do %>Outline<% end %>

Sizes

<%= render "components/badge", size: :sm do %>Small<% end %>
<%= render "components/badge", size: :md do %>Medium<% end %>
<%= render "components/badge", size: :lg do %>Large<% end %>

With Icons

<%= render "components/badge", variant: :success do %>
  <%= icon_for :check, class: "size-3" %>
  Verified
<% end %>

<%= render "components/badge", variant: :warning do %>
  <%= icon_for :clock, class: "size-3" %>
  Pending
<% end %>

API Reference

Badge

Parameter Type Default Description
variant Symbol :default :default, :primary, :secondary, :destructive, :success, :warning, :info, :outline; :error is accepted as an alias of :destructive
size Symbol :md :sm, :md, :lg; :default is accepted as an alias of :md
css_classes String "" Additional CSS classes
html_options Hash {} Additional HTML attributes
--- URL: https://maquina.app/documentation/components/breadcrumbs/ Title: Breadcrumbs ---

Usage

<%= render "components/breadcrumbs" do %>
  <%= render "components/breadcrumbs/list" do %>
    <%= render "components/breadcrumbs/item" do %>
      <%= render "components/breadcrumbs/link", href: "/" do %>Home<% end %>
    <% end %>
    <%= render "components/breadcrumbs/separator" %>
    <%= render "components/breadcrumbs/item" do %>
      <%= render "components/breadcrumbs/link", href: "/components" do %>Components<% end %>
    <% end %>
    <%= render "components/breadcrumbs/separator" %>
    <%= render "components/breadcrumbs/item" do %>
      <%= render "components/breadcrumbs/page" do %>Breadcrumbs<% end %>
    <% end %>
  <% end %>
<% end %>

Using Helper

<%= breadcrumbs({"Home" => root_path, "Users" => users_path}, "John Doe") %>

Examples

With Icons

<%= render "components/breadcrumbs/link", href: "/" do %>
  <%= icon_for(:home, class: "size-4") %>
  Home
<% end %>

Custom Separators

<%= render "components/breadcrumbs/separator", icon: :slash %>
<%= render "components/breadcrumbs/separator", icon: :arrow_right %>

With Ellipsis

<%= render "components/breadcrumbs/item" do %>
  <%= render "components/breadcrumbs/ellipsis" %>
<% end %>

Responsive

<%= responsive_breadcrumbs(
  {"Home" => "/", "Docs" => "/docs", "Components" => "/components"},
  "Breadcrumbs"
) %>

Items collapse only when they do not fit, and come back when they do. The controller measures the trail against its container and hides middle items one at a time, from the first one inward, until the row fits — so the ellipsis always stands for the items directly behind it. Widen the container and the hidden items return; there is no one-way collapse and no item-count threshold.

The container is what is measured, not the window, so a breadcrumb inside a collapsing sidebar or a resizing panel re-fits when that panel moves. If a single current-page title is too long to help by collapsing anything, it truncates with an ellipsis as a last resort.

The ellipsis dropdown

When items are collapsed, the becomes a button. Clicking it opens a menu listing the hidden items as links, so nothing in the trail becomes unreachable. It renders in the top layer as a popover — light dismiss and Escape work natively — and needs no markup from you beyond responsive_breadcrumbs.

API Reference

Breadcrumbs

Parameter Type Default Description
responsive Boolean false Collapse middle items when the trail does not fit its container, and restore them when it does
collapse_after Integer Deprecated, ignored. Removed in 0.8.0. It collapsed on item count without consulting available width, which also collapsed a trail with room to spare; space-based collapsing works now
css_classes String "" Additional CSS classes
html_options Hash {} Additional HTML attributes

Breadcrumbs List

Parameter Type Default Description
css_classes String "" Additional CSS classes
html_options Hash {} Additional HTML attributes

Breadcrumbs Item

Parameter Type Default Description
css_classes String "" Additional CSS classes
html_options Hash {} Additional HTML attributes

Breadcrumbs Link

Parameter Type Default Description
href String required Link destination
css_classes String "" Additional CSS classes
html_options Hash {} Additional HTML attributes

Breadcrumbs Page

Parameter Type Default Description
css_classes String "" Additional CSS classes
html_options Hash {} Additional HTML attributes

Breadcrumbs Separator

Parameter Type Default Description
icon Symbol :chevron_right Icon name, or :custom to use block
css_classes String "" Additional CSS classes
html_options Hash {} Additional HTML attributes

Breadcrumbs Ellipsis

Parameter Type Default Description
css_classes String "" Additional CSS classes
html_options Hash {} Additional HTML attributes
--- URL: https://maquina.app/documentation/components/calendar/ Title: Calendar ---

Usage

<%= render "components/calendar" %>

With Selected Date

<%= render "components/calendar", selected: Date.today %>

Examples

Single Selection

<%= render "components/calendar",
      mode: :single,
      selected: Date.today %>

Range Selection

<%= render "components/calendar",
      mode: :range,
      selected: Date.today,
      selected_end: Date.today + 5 %>

With Date Constraints

<%= render "components/calendar",
      min_date: Date.today,
      max_date: Date.today + 14 %>

Week Starting Monday

<%= render "components/calendar",
      week_starts_on: :monday %>

Form Integration

<%= form_with model: @event do |f| %>
  <%= render "components/calendar",
        selected: @event.date,
        input_name: "event[date]" %>
<% end %>

Range Form Integration

<%= render "components/calendar",
      mode: :range,
      input_name: "booking[check_in]",
      input_name_end: "booking[check_out]" %>

API Reference

Calendar

Parameter Type Default Description
selected Date, String nil Selected start date
selected_end Date, String nil Selected end date (range mode)
month Integer nil Display month (1-12)
year Integer nil Display year
mode Symbol :single :single or :range
min_date Date, String nil Minimum selectable date
max_date Date, String nil Maximum selectable date
disabled_dates Array [] Dates to disable
show_outside_days Boolean true Show days from adjacent months
week_starts_on Symbol :sunday :sunday or :monday
cell_size String nil Custom cell size CSS value
input_name String nil Hidden input name for forms
input_name_end String nil End date hidden input name
css_classes String "" Additional CSS classes
html_options Hash {} Additional HTML attributes

Calendar Header

Parameter Type Default Description
month Integer required Display month
year Integer required Display year
month_name String required Formatted month name
css_classes String "" Additional CSS classes
html_options Hash {} Additional HTML attributes
--- URL: https://maquina.app/documentation/components/card/ Title: Card ---

Usage

<%= render "components/card" do %>
  <%= render "components/card/header" do %>
    <%= render "components/card/title", text: "Card Title" %>
    <%= render "components/card/description", text: "Card description." %>
  <% end %>
  <%= render "components/card/content" do %>
    <p>Card content goes here.</p>
  <% end %>
  <%= render "components/card/footer" do %>
    <button data-component="button" data-variant="primary">Save</button>
  <% end %>
<% end %>

Examples

Simple Card

<%= render "components/card" do %>
  <%= render "components/card/content", spacing: :full do %>
    <p>A simple card with just content.</p>
  <% end %>
<% end %>

With Header Action

<%= render "components/card" do %>
  <%= render "components/card/header", layout: :row do %>
    <div>
      <%= render "components/card/title", text: "Team Members" %>
      <%= render "components/card/description", text: "Manage your team." %>
    </div>
    <%= render "components/card/action" do %>
      <button data-component="button" data-variant="primary" data-size="sm">Add</button>
    <% end %>
  <% end %>
  <%= render "components/card/content" do %>
    <p class="text-sm text-muted-foreground">No members yet.</p>
  <% end %>
<% end %>

With Footer

<%= render "components/card" do %>
  <%= render "components/card/header" do %>
    <%= render "components/card/title", text: "Settings" %>
  <% end %>
  <%= render "components/card/content" do %>
    <p>Configure your preferences.</p>
  <% end %>
  <%= render "components/card/footer", align: :end do %>
    <button data-component="button" data-variant="outline">Cancel</button>
    <button data-component="button" data-variant="primary">Save</button>
  <% end %>
<% end %>

API Reference

Card

Parameter Type Default Description
css_classes String "" Additional CSS classes
html_options Hash {} Additional HTML attributes

Card Header

Parameter Type Default Description
layout Symbol :column :column or :row
css_classes String "" Additional CSS classes
html_options Hash {} Additional HTML attributes

Card Title

Parameter Type Default Description
text String nil Title text
content String nil HTML content via capture, or use block
size Symbol :default :default or :sm
css_classes String "" Additional CSS classes
html_options Hash {} Additional HTML attributes

Card Description

Parameter Type Default Description
text String nil Description text
content String nil HTML content via capture, or use block
css_classes String "" Additional CSS classes
html_options Hash {} Additional HTML attributes

Card Action

Parameter Type Default Description
css_classes String "" Additional CSS classes
html_options Hash {} Additional HTML attributes

Card Content

Parameter Type Default Description
spacing Symbol :default :default or :full (when no header)
css_classes String "" Additional CSS classes
html_options Hash {} Additional HTML attributes

Card Footer

Parameter Type Default Description
align Symbol :start :start, :center, :end, :between
spacing Symbol :default :default or :full (when no content)
css_classes String "" Additional CSS classes
html_options Hash {} Additional HTML attributes
--- URL: https://maquina.app/documentation/components/combobox/ Title: Combobox ---

The trigger renders its own up/down chevron, and the search field inside the popover carries a focus ring like any other form control.

Usage

<%= render "components/combobox", placeholder: "Select..." do |combobox_id| %>
  <%= render "components/combobox/trigger", for_id: combobox_id, placeholder: "Select..." %>

  <%= render "components/combobox/content", id: combobox_id do %>
    <%= render "components/combobox/input", placeholder: "Search..." %>

    <%= render "components/combobox/list" do %>
      <%= render "components/combobox/option", value: "one" do %>Option One<% end %>
      <%= render "components/combobox/option", value: "two" do %>Option Two<% end %>
    <% end %>

    <%= render "components/combobox/empty" %>
  <% end %>
<% end %>

Examples

With Selection

<%= render "components/combobox/option", value: "active", selected: true do %>Active<% end %>
<%= render "components/combobox/option", value: "archived", disabled: true do %>Archived<% end %>

With Groups

<%= render "components/combobox/list" do %>
  <%= render "components/combobox/group" do %>
    <%= render "components/combobox/label", text: "Backend" %>
    <%= render "components/combobox/option", value: "ruby" do %>Ruby<% end %>
  <% end %>

  <%= render "components/combobox/separator" %>

  <%= render "components/combobox/group" do %>
    <%= render "components/combobox/label", text: "Frontend" %>
    <%= render "components/combobox/option", value: "js" do %>JavaScript<% end %>
  <% end %>
<% end %>

API Reference

Combobox

Parameter Type Default Description
id String nil Custom ID; defaults to a deterministic id derived from the input name
name String nil Form input name
value String nil Pre-selected value
placeholder String "Select..." Placeholder text
css_classes String "" Additional CSS classes
html_options Hash {} Additional HTML attributes

Combobox Trigger

Parameter Type Default Description
for_id String required ID of content popover
placeholder String "Select..." Placeholder text
variant Symbol :outline Button variant
size Symbol :default Button size
css_classes String "" Additional CSS classes
html_options Hash {} Additional HTML attributes

Combobox Content

Parameter Type Default Description
id String required Popover ID
align Symbol :start :start, :center, :end
width Symbol :default :sm, :default, :md, :lg, :full
css_classes String "" Additional CSS classes
html_options Hash {} Additional HTML attributes

Combobox Input

Parameter Type Default Description
placeholder String "Search..." Search placeholder
css_classes String "" Additional CSS classes
html_options Hash {} Additional HTML attributes

Combobox Option

Parameter Type Default Description
value String required Option value
selected Boolean false Whether selected
disabled Boolean false Whether disabled
css_classes String "" Additional CSS classes
html_options Hash {} Additional HTML attributes

Combobox Empty

Parameter Type Default Description
text String "No results found." Empty state message
content String nil Captured HTML via capture, or use block
css_classes String "" Additional CSS classes
html_options Hash {} Additional HTML attributes

Combobox Group

Parameter Type Default Description
css_classes String "" Additional CSS classes
html_options Hash {} Additional HTML attributes

Combobox Label

Parameter Type Default Description
text String nil Label text
content String nil HTML content via capture, or use block
css_classes String "" Additional CSS classes
html_options Hash {} Additional HTML attributes

Combobox List

Parameter Type Default Description
css_classes String "" Additional CSS classes
html_options Hash {} Additional HTML attributes

Builder Helper

The combobox helper wires the trigger and content ids together for you:

<%= combobox placeholder: "Select framework...", name: "framework" do |cb| %>
  <% cb.trigger %>
  <% cb.content do %>
    <% cb.input placeholder: "Search..." %>
    <% cb.list do %>
      <% cb.group do %>
        <% cb.label "Frontend" %>
        <% cb.option value: "react" do %>React<% end %>
        <% cb.option value: "vue" do %>Vue<% end %>
      <% end %>
      <% cb.separator %>
      <% cb.option value: "rails" do %>Rails<% end %>
    <% end %>
    <% cb.empty %>
  <% end %>
<% end %>

For flat option lists, combobox_simple renders everything from data:

<%= combobox_simple placeholder: "Select framework...",
      name: "framework",
      options: [
        { value: "nextjs", label: "Next.js" },
        { value: "remix", label: "Remix" }
      ] %>
--- URL: https://maquina.app/documentation/components/date-picker/ Title: Date Picker ---

Usage

<%= render "components/date_picker",
      mode: :single,
      placeholder: "Select a date",
      input_name: "event_date" %>

Examples

Range Selection

<%= render "components/date_picker",
      mode: :range,
      placeholder: "Select date range",
      input_name: "start_date",
      input_name_end: "end_date" %>

With Pre-selected Date

<%= render "components/date_picker",
      mode: :single,
      selected: Date.today,
      input_name: "event_date" %>

With Date Constraints

<%= render "components/date_picker",
      min_date: Date.today,
      max_date: Date.today + 30,
      placeholder: "Select within 30 days" %>

Disabled

<%= render "components/date_picker",
      selected: Date.today,
      disabled: true %>

API Reference

Date Picker

Parameter Type Default Description
selected Date, String nil Pre-selected date
selected_end Date, String nil End date for range mode
mode Symbol :single :single or :range
min_date Date, String nil Minimum selectable date
max_date Date, String nil Maximum selectable date
disabled_dates Array [] Array of dates to disable
show_outside_days Boolean true Show days from adjacent months
week_starts_on Symbol :sunday :sunday or :monday
placeholder String nil Placeholder text
input_name String nil Name for hidden form input
input_name_end String nil End date input name (range mode)
id String nil Custom ID; defaults to a deterministic id derived from the input name
disabled Boolean false Whether disabled
required Boolean false Mark input as required
css_classes String "" Additional CSS classes
html_options Hash {} Additional HTML attributes

Turbo Drive

The date picker controller automatically closes the popover before Turbo caches the page. No configuration is needed — pressing the browser back button will always show the date picker in its closed state.

--- URL: https://maquina.app/documentation/components/drawer/ Title: Drawer ---

Usage

<%= render "components/drawer/provider", default_open: drawer_open? do %>
  <%= render "components/drawer", state: drawer_state do %>
    <%= render "components/drawer/header" do %>
      <%= render "components/drawer/title", text: "Drawer Title" %>
    <% end %>

    <%= render "components/drawer/content" do %>
      Drawer content
    <% end %>

    <%= render "components/drawer/footer" do %>
      Footer actions
    <% end %>
  <% end %>
<% end %>

Examples

With Trigger

The trigger can live anywhere on the page — it finds the drawer through a Stimulus outlet and mirrors its state with aria-expanded.

<%= render "components/drawer/trigger" do %>Open Drawer<% end %>

<%= render "components/drawer/provider" do %>
  <%= render "components/drawer" do %>
    <!-- content -->
  <% end %>
<% end %>

Sections and Separators

Group the drawer body into stacked sections, divided by a separator.

<%= render "components/drawer/content" do %>
  <%= render "components/drawer/section" do %>
    <%= render "components/drawer/title", text: "Filters", tag: :h3 %>
  <% end %>

  <%= render "components/drawer/separator" %>

  <%= render "components/drawer/section" do %>
    <%# More rows %>
  <% end %>
<% end %>

drawer/separator renders the separator primitive, so it keeps the primitive's 1px track while the drawer part re-spaces it for the panel.

Left Side Drawer

<%= render "components/drawer", side: :left do %>
  <!-- content -->
<% end %>

Keyboard & Accessibility

API Reference

Provider

Parameter Type Default Description
id String "drawer-provider" Element ID for stable morph matching
default_open Boolean false Initial open state
cookie_name String "drawer_state" Cookie for persistence
keyboard_shortcut String "d" Toggle shortcut (Cmd/Ctrl + key)
css_classes String "" Additional CSS classes
html_options Hash {} Additional HTML attributes

Drawer

Parameter Type Default Description
id String auto Element ID (deterministic, derived from side)
state Symbol :closed :open or :closed
side Symbol :right :left or :right
aria_label String "Drawer" Accessible name for the dialog panel
css_classes String "" Additional CSS classes
html_options Hash {} Additional HTML attributes

Trigger

Parameter Type Default Description
icon_name Symbol nil Optional icon for the toggle button
variant Symbol :default Button variant
size Symbol :default Button size
css_classes String "" Additional CSS classes
html_options Hash {} Additional HTML attributes

Other Parts

Partial Description
drawer/header Top section with title and built-in close button
drawer/title Heading inside the header. text: / content: / block, tag: (default :h2)
drawer/description Supporting line under the title. text: / content: / block, tag: (default :p)
drawer/content Scrollable middle section
drawer/footer Bottom section for actions
drawer/section Groups related rows inside the content area. Container — pass a block
drawer/separator Divider between sections. orientation: (default :horizontal)
drawer/close Close button (X icon)

Helper Methods

Method Description
drawer_state(cookie_name) Returns :open or :closed from the cookie
drawer_open?(cookie_name) Returns true if open
drawer_closed?(cookie_name) Returns true if closed

Turbo Drive

The drawer controller integrates with Turbo Drive to keep state correct across navigations:

--- URL: https://maquina.app/documentation/components/dropdown-menu/ Title: Dropdown Menu ---

Usage

<%= render "components/dropdown_menu" do %>
  <%= render "components/dropdown_menu/trigger" do %>Open Menu<% end %>

  <%= render "components/dropdown_menu/content" do %>
    <%= render "components/dropdown_menu/item", href: "#" do %>Profile<% end %>
    <%= render "components/dropdown_menu/item", href: "#" do %>Settings<% end %>
    <%= render "components/dropdown_menu/separator" %>
    <%= render "components/dropdown_menu/item", href: "#" do %>Logout<% end %>
  <% end %>
<% end %>

Examples

With Icons

<%= render "components/dropdown_menu/item", href: "#" do %>
  <%= icon_for :user, class: "size-4" %>
  Profile
<% end %>
<%= render "components/dropdown_menu/item", href: "#", variant: :destructive do %>
  <%= icon_for :log_out, class: "size-4" %>
  Logout
<% end %>

With Shortcuts

<%= render "components/dropdown_menu/item", href: "#" do %>
  Undo
  <%= render "components/dropdown_menu/shortcut" do %>⌘Z<% end %>
<% end %>

Icon Trigger

The default trigger renders its own chevron, which rotates 180° while the menu is open. Reach for as_child when you need different content — an icon-only button, an sr-only label — not merely to get an affordance. Note that as_child hands you the whole button: data-dropdown-menu-target="trigger", data-action="dropdown-menu#toggle", aria-haspopup and aria-expanded are all yours to write. The controller updates aria-expanded at runtime, but only if the attribute is there to begin with.

<%= render "components/dropdown_menu/trigger", as_child: true do %>
  <button type="button"
          data-component="button"
          data-variant="ghost"
          data-size="icon"
          data-dropdown-menu-target="trigger"
          data-action="dropdown-menu#toggle"
          aria-haspopup="menu"
          aria-expanded="false">
    <%= icon_for :more_horizontal, class: "size-4" %>
  </button>
<% end %>

Placement

Since 0.7.1 the menu measures itself when it opens and flips above the trigger if it would otherwise open past the bottom of the viewport. It flips only when the space above genuinely fits — flipping into a gap that is also too small trades one clipped menu for another — and it re-measures from the placement you asked for each time, so a menu that flipped in a short window returns to its default once the window grows.

side: is therefore an initial preference rather than a fixed position. The controller writes the resolved side to data-side on the content element, which is what the CSS positions against. Only the block axis flips; :left and :right are left alone.

The menu button behaves the same way. If you carry your own collision or flip controller, you can delete it — bin/rails maquina:doctor reports it as app-level-dropdown-flip.

API Reference

Dropdown Menu

Parameter Type Default Description
css_classes String "" Additional CSS classes
html_options Hash {} Additional HTML attributes

Dropdown Menu Trigger

Parameter Type Default Description
variant Symbol :outline Button variant when as_child is false
size Symbol :default Button size when as_child is false
as_child Boolean false Use custom trigger markup
css_classes String "" Additional CSS classes
html_options Hash {} Additional HTML attributes

Dropdown Menu Content

Parameter Type Default Description
align Symbol :start :start, :center, :end
side Symbol :bottom :top, :bottom, :left, :right — a preference; see Placement below
width Symbol :default :default, :sm, :md, :lg
css_classes String "" Additional CSS classes
html_options Hash {} Additional HTML attributes

Dropdown Menu Item

Parameter Type Default Description
href String nil URL, renders link if provided
method Symbol nil HTTP method (:delete, :post, etc.)
variant Symbol :default :default or :destructive
disabled Boolean false Whether disabled
css_classes String "" Additional CSS classes
html_options Hash {} Additional HTML attributes

Dropdown Menu Label

Parameter Type Default Description
text String nil Label text
content String nil Captured HTML via capture, or use block
inset Boolean false Align with icon items
css_classes String "" Additional CSS classes
html_options Hash {} Additional HTML attributes

Dropdown Menu Separator

Parameter Type Default Description
css_classes String "" Additional CSS classes
html_options Hash {} Additional HTML attributes

Dropdown Menu Group

Parameter Type Default Description
css_classes String "" Additional CSS classes
html_options Hash {} Additional HTML attributes

Dropdown Menu Shortcut

Parameter Type Default Description
text String nil Shortcut text
content String nil Captured HTML via capture, or use block
css_classes String "" Additional CSS classes
html_options Hash {} Additional HTML attributes

Builder Helper

The dropdown_menu helper builds the whole menu — trigger, content, items, separators, and shortcuts — without composing partials by hand:

<%= dropdown_menu do |menu| %>
  <% menu.trigger do %>
    <%= icon_for :more_horizontal %>
  <% end %>
  <% menu.content align: :end, width: :md do %>
    <% menu.label "Actions" %>
    <% menu.item "Edit", href: edit_path, icon: :pencil do |item| %>
      <% item.shortcut "⌘E" %>
    <% end %>
    <% menu.separator %>
    <% menu.item "Delete", href: delete_path, method: :delete, variant: :destructive, icon: :trash %>
  <% end %>
<% end %>

For data-driven menus, dropdown_menu_simple renders trigger and items from a list:

<%= dropdown_menu_simple "Actions", items: [
  { label: "Edit", href: edit_path, icon: :pencil },
  { label: "Delete", href: delete_path, method: :delete, destructive: true }
] %>
Builder Method Description
menu.trigger(variant:, size:, as_child:, &block) Renders the trigger button
menu.content(align:, side:, width:, &block) Positioned menu container
menu.item(label, href:, method:, icon:, variant:, disabled:, &block) Menu item; yields an item builder for shortcut(text)
menu.label(text, inset:) Section heading
menu.separator / menu.group(&block) Divider / logical grouping
--- URL: https://maquina.app/documentation/components/empty/ Title: Empty ---

Usage

<%= render "components/empty" do %>
  <%= render "components/empty/header" do %>
    <%= render "components/empty/media", icon: :inbox %>
    <%= render "components/empty/title", text: "No messages" %>
    <%= render "components/empty/description", text: "Messages you receive will appear here." %>
  <% end %>
<% end %>

Examples

With Action

<%= render "components/empty" do %>
  <%= render "components/empty/header" do %>
    <%= render "components/empty/media", icon: :folder %>
    <%= render "components/empty/title", text: "No projects yet" %>
    <%= render "components/empty/description", text: "Get started by creating your first project." %>
  <% end %>
  <%= render "components/empty/content" do %>
    <button data-component="button" data-variant="primary">Create project</button>
  <% end %>
<% end %>

Outline Variant

<%= render "components/empty", variant: :outline do %>
  <%= render "components/empty/header" do %>
    <%= render "components/empty/media", icon: :upload %>
    <%= render "components/empty/title", text: "Drop files here" %>
  <% end %>
<% end %>

Compact Size

<%= render "components/empty", size: :compact do %>
  <%= render "components/empty/header" do %>
    <%= render "components/empty/media", icon: :search %>
    <%= render "components/empty/title", text: "No results found" %>
  <% end %>
<% end %>

API Reference

Empty

Parameter Type Default Description
variant Symbol :default :default or :outline
size Symbol :default :default or :compact
css_classes String "" Additional CSS classes
html_options Hash {} Additional HTML attributes

Empty Header

Parameter Type Default Description
css_classes String "" Additional CSS classes
html_options Hash {} Additional HTML attributes

Empty Media

Parameter Type Default Description
icon Symbol nil Icon name
content String nil Captured HTML via capture, or use block
variant Symbol :icon :icon or :avatar
css_classes String "" Additional CSS classes
html_options Hash {} Additional HTML attributes

Empty Title

Parameter Type Default Description
text String nil Title text
content String nil Captured HTML via capture, or use block
css_classes String "" Additional CSS classes
html_options Hash {} Additional HTML attributes

Empty Description

Parameter Type Default Description
text String nil Description text
content String nil Captured HTML via capture, or use block
css_classes String "" Additional CSS classes
html_options Hash {} Additional HTML attributes

Empty Content

Parameter Type Default Description
css_classes String "" Additional CSS classes
html_options Hash {} Additional HTML attributes

Helper Methods

Three helpers cover the common empty-state patterns without composing partials:

<%= empty_state title: "No documents", description: "Create your first document.", icon: :folder do %>
  <%= link_to "New document", new_document_path, data: { component: "button", variant: "primary" } %>
<% end %>

<%= empty_search_state query: params[:q], reset_path: documents_path %>

<%= empty_list_state resource_name: "project", new_path: new_project_path %>
Method Description
empty_state(title:, description:, icon:, variant:, size:, &block) General empty state; block renders action content
empty_search_state(query:, reset_path:, size:) No-results state for searches, with optional reset link
empty_list_state(resource_name:, new_path:, icon:, size:) First-run state for empty collections, with optional create link
--- URL: https://maquina.app/documentation/components/form/ Title: Form ---

Usage

<%= form_with model: @user, data: { component: "form" } do |f| %>
  <div data-form-part="group">
    <%= f.label :email, data: { component: "label" } %>
    <%= f.email_field :email, data: { component: "input" }, placeholder: "you@example.com" %>
  </div>

  <%= f.submit "Sign in", data: { component: "button", variant: "primary" } %>
<% end %>

Examples

Input

<%= f.text_field :name, data: { component: "input" }, placeholder: "Full name" %>
<%= f.text_field :name, data: { component: "input", size: "sm" } %>
<%= f.text_field :name, data: { component: "input", size: "lg" } %>

Textarea

<%= f.text_area :bio, data: { component: "textarea" }, rows: 4 %>

Select

<%= f.select :country, options, {}, data: { component: "select" } %>

Checkbox

<label class="flex items-center gap-2">
  <%= f.check_box :terms, data: { component: "checkbox" } %>
  <span class="text-sm">Accept terms</span>
</label>

Radio

<label class="flex items-center gap-2">
  <%= f.radio_button :plan, "pro", data: { component: "radio" } %>
  <span class="text-sm">Pro</span>
</label>

Switch

<label class="flex items-center gap-3">
  <%= f.check_box :notifications, data: { component: "switch" } %>
  <span class="text-sm">Enable notifications</span>
</label>

Button

<button data-component="button" data-variant="primary">Primary</button>
<button data-component="button" data-variant="secondary">Secondary</button>
<button data-component="button" data-variant="destructive">Destructive</button>
<button data-component="button" data-variant="outline">Outline</button>
<button data-component="button" data-variant="ghost">Ghost</button>
<button data-component="button" data-variant="link">Link</button>

Error States

The error state is driven by aria-invalid on the field. Set it from your model and the border, the focus ring and the message all follow:

<div data-form-part="group">
  <%= f.label :email, data: { component: "label" } %>
  <%= f.email_field :email, data: { component: "input" },
      aria: { invalid: @user.errors[:email].any?,
              describedby: ("email-error" if @user.errors[:email].any?) } %>
  <% if @user.errors[:email].any? %>
    <p data-form-part="error" id="email-error"><%= @user.errors[:email].first %></p>
  <% end %>
</div>

Rails' own .field_with_errors wrapper is styled too, so an app that leans on the default wrapper gets the same treatment without setting anything.

Do not rely on :invalid alone

Before 0.7.1 the engine also matched :invalid:not(:placeholder-shown), which meant a required field with no placeholder painted the error state on first paint — before focus, before blur, before submit — while telling screen readers nothing, because no aria-invalid accompanied it.

That is now :user-invalid, which only matches once the reader has actually interacted with the field. Two consequences:

bin/rails maquina:doctor reports the second case as invalid-styling-without-aria.

Error text colour

The message reads --destructive-text, and the invalid border reads --destructive-border. Both default to what they painted before, so the installer's palette needs no change. If your palette is the saturated shadcn kind — --destructive a strong red, --destructive-foreground near-white — add:

:root {
  --destructive-text: var(--destructive);
  --destructive-border: var(--destructive);
}

See Theming for why the two palettes differ.

API Reference

Form Container

Attribute Description
data-component="form" Grid layout with gap
data-form-part="group" Field group container
data-form-part="description" Help text styling
data-form-part="error" Error message styling; colour comes from --destructive-text
data-form-part="actions" Submit area container

Input

Attribute Values Description
data-component input Text input styling
data-size sm, lg Size variant

Textarea

Attribute Values Description
data-component textarea Textarea styling

Select

Attribute Values Description
data-component select Native select styling

Checkbox

Attribute Values Description
data-component checkbox Checkbox styling

Radio

Attribute Values Description
data-component radio Radio button styling

Switch

Attribute Values Description
data-component switch Toggle switch styling

Label

Attribute Values Description
data-component label Label styling
data-required (presence) Shows required indicator

Button

Attribute Values Description
data-component button Button styling
data-variant primary, secondary, destructive, outline, ghost, link Visual style
data-size sm, lg, icon, icon-sm, icon-lg Size variant
--- URL: https://maquina.app/documentation/components/header/ Title: Header ---

Quick Reference

Parameters

Parameter Type Default Description
css_classes String "" Additional CSS classes
**html_options Hash {} HTML attributes (id:, data:, etc.)

Data Attributes

Component Identifier

Attribute Element Description
data-component="header" <header> Main component identifier

Basic Usage

<%= render "components/header" do %>
  <%= render "components/sidebar/trigger" %>
  <%= render "components/separator", orientation: :vertical %>
  <%= breadcrumbs({"Dashboard" => dashboard_path}, @page_title) %>
<% end %>

Examples

With Breadcrumbs

<%= render "components/header" do %>
  <%= render "components/sidebar/trigger" %>
  <%= render "components/separator", orientation: :vertical %>
  <%= breadcrumbs(
    {"Dashboard" => dashboard_path, "Users" => users_path},
    "John Doe"
  ) %>
<% end %>

With Actions

<%= render "components/header" do %>
  <%= render "components/sidebar/trigger" %>
  <%= render "components/separator", orientation: :vertical %>
  <%= breadcrumbs({"Projects" => projects_path}, @project.name) %>
  
  <div class="ml-auto flex items-center gap-2">
    <%= link_to "Edit", edit_project_path(@project), data: { component: "button", variant: "outline", size: "sm" } %>
    <%= link_to "Delete", project_path(@project), data: { component: "button", variant: "destructive", size: "sm" }, method: :delete %>
  </div>
<% end %>
<%= render "components/header" do %>
  <%= render "components/sidebar/trigger" %>
  <%= render "components/separator", orientation: :vertical %>
  
  <div class="flex-1 max-w-md">
    <%= form_with url: search_path, method: :get, class: "relative" do |f| %>
      <%= icon_for :search, class: "absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" %>
      <%= f.search_field :q, data: { component: "input" }, class: "pl-10 h-8", placeholder: "Search..." %>
    <% end %>
  </div>
  
  <div class="ml-auto flex items-center gap-2">
    <%= render "components/dropdown_menu" do %>
    <% end %>
  </div>
<% end %>

Simple Page Title

<%= render "components/header" do %>
  <%= render "components/sidebar/trigger" %>
  <%= render "components/separator", orientation: :vertical %>
  <h1 class="text-sm font-medium">Dashboard</h1>
<% end %>

Real-World Patterns

Standard App Header

<%= render "components/header" do %>
  <%= render "components/sidebar/trigger" %>
  <%= render "components/separator", orientation: :vertical %>
  
  <%= responsive_breadcrumbs(@breadcrumb_links, @breadcrumb_current) %>
  
  <div class="ml-auto flex items-center gap-3">
    <button type="button" data-component="button" data-variant="ghost" data-size="icon-sm" class="relative">
      <%= icon_for :bell, class: "size-4" %>
      <span class="absolute -top-1 -right-1 size-4 rounded-full bg-destructive text-destructive-foreground text-xs flex items-center justify-center">3</span>
    </button>
    
    <%= dropdown_menu do |menu| %>
      <% menu.trigger variant: :ghost, size: :sm do %>
        <%= image_tag current_user.avatar, class: "size-6 rounded-full" %>
      <% end %>
      <% menu.content align: :end do %>
        <% menu.label { current_user.name } %>
        <% menu.separator %>
        <% menu.item "Profile", href: profile_path, icon: :user %>
        <% menu.item "Settings", href: settings_path, icon: :settings %>
        <% menu.separator %>
        <% menu.item "Logout", href: logout_path, method: :delete, icon: :log_out %>
      <% end %>
    <% end %>
  </div>
<% end %>

With Tabs

<%= render "components/header" do %>
  <%= render "components/sidebar/trigger" %>
  <%= render "components/separator", orientation: :vertical %>
  
  <nav class="flex items-center gap-1">
    <%= link_to "Overview", project_path(@project), 
      class: "px-3 py-1.5 text-sm rounded-md #{'bg-accent text-accent-foreground' if current_page?(project_path(@project))}" %>
    <%= link_to "Tasks", project_tasks_path(@project),
      class: "px-3 py-1.5 text-sm rounded-md #{'bg-accent text-accent-foreground' if current_page?(project_tasks_path(@project))}" %>
    <%= link_to "Settings", edit_project_path(@project),
      class: "px-3 py-1.5 text-sm rounded-md #{'bg-accent text-accent-foreground' if current_page?(edit_project_path(@project))}" %>
  </nav>
<% end %>

Theme Variables

var(--background)
var(--border)

Customization

Fixed Height

The header has a fixed height for consistency with sidebar layouts:

[data-component="header"] {
  @apply h-14;
}
<%= render "components/header", css_classes: "sticky top-0 z-50" do %>
<% end %>

Accessibility


File Structure

app/views/components/
└── _header.html.erb

app/assets/stylesheets/header.css
docs/header.md
--- URL: https://maquina.app/documentation/components/ Title: Components ---

Production-ready UI components for Rails applications. Copy-paste ERB partials styled with Tailwind CSS 4.0 and optional Stimulus controllers.

What you get:

Maquina Components light theme Maquina Components dark theme

Already on 0.5.1? 0.6.0 is a deliberately breaking release: engine CSS moved into @layer components, and radius, elevation, focus rings and weights became tokens. One change affects every existing app and fails silently — the unlayered * rule in your installed theme.css. Run the scanner, then read the guide:

bundle update maquina-components
bin/rails maquina:doctor

Upgrading to 0.6.0 · Theming

Latest release: 0.7.1. No API changes, but not purely additive: a required field with no placeholder used to paint the error state from first paint, and the fix means an app that renders server-side errors without setting aria-invalid loses its error border. Field error text gains its own --destructive-text token — one line of theme CSS if your palette is the saturated kind. The dropdown menu and menu button now flip when they hit the bottom of the viewport, and all eighteen leaf partials accept a block. maquina:doctor gained five rules for this release.

What changes in 0.7.1 · Theming


Demo Application

View Live Demo →

Explore all components in action without installing anything. The demo showcases light/dark themes, color themes, and responsive layouts.

For local development, clone the components repository:

git clone https://github.com/maquina-app/maquina_components.git
cd maquina_components/test/dummy
bin/rails server

Visit http://localhost:3000 to explore the components locally.


Quick Start

1. Add the Gem

# Gemfile
gem "maquina-components"
bundle install

2. Run the Install Generator

bin/rails generate maquina_components:install

This adds the engine CSS import, theme variables (shadcn/ui convention), a shape/state token block for radius, elevation, focus rings and weights, and a helper file for icon customization.

Re-running the generator is safe: it is idempotent, appends each block only once, and never rewrites your palette.

3. Start Using Components

<%= render "components/card" do %>
  <%= render "components/card/header" do %>
    <%= render "components/card/title", text: "Welcome" %>
  <% end %>
  <%= render "components/card/content" do %>
    <p>Your content here</p>
  <% end %>
<% end %>

Every partial takes its content three ways, and they are interchangeable — containers and leaves alike:

<%= render "components/card/title", text: "Welcome" %>
<%= render "components/card/title", content: "Welcome" %>
<%= render "components/card/title" do %>Welcome<% end %>

text: wins when present, then content:, then the block. Pass a block whenever the content is markup rather than a string. Before 0.7.1 half the leaf partials silently dropped the block, so if you worked around that with capture, you no longer need to.

For form elements, use data attributes:

<%= form_with model: @user do |f| %>
  <%= f.text_field :email, data: { component: "input" } %>
  <%= f.submit "Save", data: { component: "button", variant: "primary" } %>
<% end %>

AI-Assisted Development

Use the Maquina UI Standards Claude Code plugin to generate views that follow component conventions automatically.

Instead of correcting AI-generated code (“use the card partial, not a div”), the plugin teaches Claude your component patterns:

> Create the users index view with a table showing name, email, and status

Claude generates code using your actual components — proper partials, correct data attributes, and consistent patterns.


Available Components

Layout

Component Description
Sidebar Collapsible navigation with mobile support and keyboard shortcuts
Header Page header for sidebar layouts with breadcrumbs and actions
Drawer Slide-out panel with overlay, persistence, and keyboard shortcut

Content

Component Description
Card Content containers with header, body, and footer sections
Alert Callouts with 4 variants and icon support
Badge Status indicators with 7 variants and 3 sizes
Table Responsive data tables with striped and bordered variants, sticky headers, and a collection helper
Empty State Placeholder for no-data scenarios with icons and actions
Separator Horizontal or vertical divider
Stats Metric cards in a responsive grid
Component Description
Breadcrumbs Navigation with responsive collapsing support
Dropdown Menu Actions menu triggered by a button with keyboard navigation
Pagination Navigation for paginated content with Pagy integration

Interactive

Component Description
Calendar Date selection with single and range modes
Combobox Searchable dropdown with keyboard navigation and filtering
Date Picker Popover calendar triggered by a button for date selection
Toggle Group Single or multiple selection button groups

Feedback

Component Description
Toast Non-intrusive notifications with auto-dismiss and variants

Forms

Component Description
Form Components Inputs, selects, checkboxes styled with data attributes

Prerequisites

The generator requires tailwindcss-rails:

bundle add tailwindcss-rails
bin/rails tailwindcss:install

Stimulus Setup

Interactive components (Sidebar, Dropdown Menu, Toggle Group, Breadcrumbs, Combobox, Toast) require Stimulus. With importmaps:

# config/importmap.rb
pin "@hotwired/turbo-rails", to: "turbo.min.js"
pin "@hotwired/stimulus", to: "stimulus.min.js"
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js"
pin_all_from "app/javascript/controllers", under: "controllers"
// app/javascript/application.js
import "@hotwired/turbo-rails"
import { Application } from "@hotwired/stimulus"
import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"

const application = Application.start()
application.debug = false
window.Stimulus = application

eagerLoadControllersFrom("controllers", application)

Static components (Badge, Card, Alert, Button, form elements) work without JavaScript.

Extending Component Behavior

Every component merges your data: hash with its own data attributes. Identity keys (component, variant, size) always win, but controller and action concatenate — so you can attach your own Stimulus behavior to any component without losing the built-in one:

<%= render "components/combobox", name: "country",
      data: { controller: "analytics", action: "change->analytics#track" } %>
<%# renders data-controller="combobox analytics" %>

Icons

Components render icons through the icon_for helper, which falls back to a built-in set of inline SVGs (check, chevrons, calendar, search, mail, trash, and more):

<%= icon_for :check, class: "size-4" %>
<%= icon_for :trash, class: "size-4", stroke_width: 1.5 %>

To use your own icon system (Heroicons, Lucide, inline SVG files), override main_icon_svg_for in the generated MaquinaComponentsHelpericon_for consults it first and only falls back to the built-ins when it returns nil:

# app/helpers/maquina_components_helper.rb
def main_icon_svg_for(name)
  lucide_icon(name)
end

Or return the SVG yourself, a name at a time:

module MaquinaComponentsHelper
  def main_icon_svg_for(name)
    case name
    when :home
      <<~SVG
        <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
          <path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"/>
          <path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
        </svg>
      SVG
    end
  end
end

Icons are sourced from Lucide. Copy SVG code directly from their website.

What the override does and does not reach

main_icon_svg_for backs the public icon_for helper — every icon you render, plus the component parameters that take an icon name (alert, sidebar menu items, empty states, breadcrumb separators).

It deliberately does not reach the icons an engine component renders for itself: a dropdown trigger’s chevron, the toast close button, the calendar’s arrows. Those go through an internal builtin_icon_for that only ever reads the engine’s own set, so a component looks the same in every app regardless of how you have configured icons — and so a partial override cannot leave a control without its affordance.

The practical consequence: if an engine component’s own icon looks wrong or missing, defining that name in main_icon_svg_for will not change it. That is a bug in the engine, not something to fix in your app — please report it.

Catching typos: strict_icons

An unknown icon name renders nothing at all, which is invisible in review and in production. MaquinaComponents.strict_icons raises UnknownIconError instead. It is on by default in development and test and off in production, so a typo fails loudly while you work and can never take a page down for a user.

# config/initializers/maquina_components.rb
MaquinaComponents.strict_icons = false   # opt out; unknown names render nothing

This covers both helpers. If it raises for a name you never wrote yourself, an engine component asked for an icon the engine does not ship — the message says so, and says that a main_icon_svg_for entry will not help.


Theme Variables

Colors are CSS variables following the shadcn/ui theming convention.

Each one is defined twice, which Tailwind CSS v4 requires: in :root for the value, and in @theme so it also becomes a utility (bg-primary, text-muted-foreground).

:root {
  --primary: oklch(0.488 0.243 264.376);
  --primary-foreground: oklch(0.985 0 0);
}

@theme {
  --color-primary: var(--primary);
  --color-primary-foreground: var(--primary-foreground);
}

Edit the values in :root to match your brand — the generator installs neutral grays. Adding a semantic color works the same way: declare it in :root, then mirror it in @theme if you want the utility.

Everything that is not a color — shape, focus rings, elevation, weights, control marks — is a token too, and a theme changes values, not selectors. See Theming for the full token table, ready-made themes, and how to pin a single component.

Generator Options

# Skip theme variables (if you already have them)
bin/rails generate maquina_components:install --skip-theme

# Skip helper creation
bin/rails generate maquina_components:install --skip-helper

# Skip both
bin/rails generate maquina_components:install --skip-theme --skip-helper

File Structure After Setup

app/
├── assets/tailwind/
│   └── application.css               # Theme + engine import
├── helpers/
│   └── maquina_components_helper.rb  # Icon override
├── javascript/
│   └── application.js                # Stimulus init
└── views/layouts/
    └── application.html.erb          # Layout with components

Troubleshooting

Generator Issues

“tailwindcss-rails doesn’t appear to be installed”

Install it first:

bundle add tailwindcss-rails
bin/rails tailwindcss:install

Runtime Issues

Sidebar trigger not working

Styles not applying

Dark mode not working

Icons not rendering


Next Steps

Sidebar

Build your application layout with collapsible navigation.

Card

Display content in containers with header, body, and footer.

Form Components

Style inputs, selects, and buttons with data attributes.

AI-Assisted Development

Use Claude Code to generate views with component conventions.

--- URL: https://maquina.app/documentation/components/pagination/ Title: Pagination ---

Usage

<%= render "components/pagination" do %>
  <%= render "components/pagination/content" do %>
    <%= render "components/pagination/item" do %>
      <%= render "components/pagination/previous", href: "/page/1" %>
    <% end %>
    <%= render "components/pagination/item" do %>
      <%= render "components/pagination/link", href: "/page/1" do %>1<% end %>
    <% end %>
    <%= render "components/pagination/item" do %>
      <%= render "components/pagination/link", href: "/page/2", active: true do %>2<% end %>
    <% end %>
    <%= render "components/pagination/item" do %>
      <%= render "components/pagination/link", href: "/page/3" do %>3<% end %>
    <% end %>
    <%= render "components/pagination/item" do %>
      <%= render "components/pagination/ellipsis" %>
    <% end %>
    <%= render "components/pagination/item" do %>
      <%= render "components/pagination/next", href: "/page/3" %>
    <% end %>
  <% end %>
<% end %>

Pagy Integration

For Pagy-backed collections, the pagination_nav helper renders the full pagination from a Pagy object, with Turbo-aware links:

<%= pagination_nav(@pagy, :users_path) %>

<%# Preserve query params and target a Turbo Frame %>
<%= pagination_nav(@pagy, :search_users_path,
      params: { q: params[:q] },
      turbo: { action: :replace, frame: "users" }) %>

pagination_simple renders the same navigation without page-number labels. Both return nothing when there is a single page.

Parameter Type Default Description
pagy Pagy required The Pagy pagination object
route_helper Symbol required Route helper used to build page links
params Hash {} Extra query params preserved across pages
turbo Hash { action: :replace } Turbo data attributes for the links, e.g. frame:
show_labels Boolean true Show Previous/Next text labels (pagination_nav only)
css_classes String "" Additional CSS classes
html_options Hash {} Additional HTML attributes

API Reference

Pagination

Parameter Type Default Description
css_classes String "" Additional CSS classes
html_options Hash {} Additional HTML attributes

Pagination Content

Parameter Type Default Description
css_classes String "" Additional CSS classes
html_options Hash {} Additional HTML attributes

Pagination Item

Parameter Type Default Description
css_classes String "" Additional CSS classes
html_options Hash {} Additional HTML attributes

Pagination Link

Parameter Type Default Description
href String required URL for the page
active Boolean false Whether current page
disabled Boolean false Whether disabled
css_classes String "" Additional CSS classes
html_options Hash {} Additional HTML attributes

Pagination Previous

Parameter Type Default Description
href String nil URL for previous page
label String "Previous" Button label
disabled Boolean false Whether disabled
show_label Boolean true Show text label
css_classes String "" Additional CSS classes
html_options Hash {} Additional HTML attributes

Pagination Next

Parameter Type Default Description
href String nil URL for next page
label String "Next" Button label
disabled Boolean false Whether disabled
show_label Boolean true Show text label
css_classes String "" Additional CSS classes
html_options Hash {} Additional HTML attributes

Pagination Ellipsis

Parameter Type Default Description
css_classes String "" Additional CSS classes
html_options Hash {} Additional HTML attributes
--- URL: https://maquina.app/documentation/components/separator/ Title: Separator ---

Usage

<%= render "components/separator" %>

Examples

Vertical

Use inside a flex row — for example between header actions, as the Header component does.

<div class="flex h-8 items-center">
  <span>Docs</span>
  <%= render "components/separator", orientation: :vertical %>
  <span>Source</span>
</div>

API Reference

Separator

Parameter Type Default Description
orientation Symbol :horizontal :horizontal or :vertical
css_classes String "" Additional CSS classes
html_options Hash {} Additional HTML attributes, including data
--- URL: https://maquina.app/documentation/components/sidebar/ Title: Sidebar ---

Usage

<%= render "components/sidebar/provider", default_open: sidebar_open? do %>
  <%= render "components/sidebar", state: sidebar_state do %>
    <%= render "components/sidebar/header" do %>
      <%# Logo/branding %>
    <% end %>

    <%= render "components/sidebar/content" do %>
      <%= render "components/sidebar/group", title: "Navigation" do %>
        <%= render "components/sidebar/menu" do %>
          <%= render "components/sidebar/menu_item" do %>
            <%= render "components/sidebar/menu_button",
              title: "Dashboard",
              icon_name: :home,
              url: root_path,
              active: current_page?(root_path) %>
          <% end %>
        <% end %>
      <% end %>
    <% end %>

    <%= render "components/sidebar/footer" do %>
      <%# User menu %>
    <% end %>
  <% end %>

  <%= render "components/sidebar/inset" do %>
    <%= render "components/header" do %>
      <%= render "components/sidebar/trigger" %>
    <% end %>
    <%= yield %>
  <% end %>
<% end %>

Examples

Menu Button

<%= render "components/sidebar/menu_button",
  title: "Dashboard",
  icon_name: :home,
  url: root_path,
  active: true %>

Menu Link (Avatar Style)

<%= render "components/sidebar/menu_link",
  url: profile_path,
  text_icon: "A",
  title: "ACME Corp",
  subtitle: "Workspace" %>

Menu Badge and Menu Action

Both pin to the right edge of the menu item, so they must be rendered inside menu_item, as siblings of the menu_button (or menu_link). That nesting is load-bearing: the item is the positioning context, and a menu item containing an action automatically reserves right-hand padding on its button so the icon never sits on top of the label. The demo above shows both — the 24 count on Inbox and the options control on Calendar.

<%= render "components/sidebar/menu_item" do %>
  <%= render "components/sidebar/menu_button",
    title: "Inbox", icon_name: :inbox, url: inbox_path %>
  <%= render "components/sidebar/menu_badge", text: "24" %>
  <%= render "components/sidebar/menu_action",
    label: "Inbox options",
    icon_name: :ellipsis,
    show_on_hover: true %>
<% end %>

menu_action renders a <button type="button"> by default and an <a> when you pass url:. It is icon-only, so label: is required — it becomes both the aria-label and screen-reader text. show_on_hover: true keeps the action invisible until the item is hovered or focused.

Group Action

<%= render "components/sidebar/group", title: "Projects" do %>
  <%= render "components/sidebar/group_action",
    label: "Add project", url: new_project_path %>

  <%= render "components/sidebar/menu" do %>
    <%# ... %>
  <% end %>
<% end %>

Separator

<%= render "components/sidebar/separator" %>

Renders the separator primitive, so it keeps the primitive's 1px track while the sidebar part re-spaces it and swaps in the sidebar's own border token.

API Reference

Provider

Parameter Type Default Description
id String "sidebar-provider" Element ID for stable morph matching
default_open Boolean true Initial open state
variant Symbol :inset Visual variant
cookie_name String "sidebar_state" Cookie for persistence
keyboard_shortcut String "b" Toggle shortcut (Cmd/Ctrl+key)
css_classes String "" Additional CSS classes
html_options Hash {} Additional HTML attributes

Sidebar

Parameter Type Default Description
id String auto Element ID
state Symbol :collapsed :expanded or :collapsed
collapsible Symbol :offcanvas :offcanvas, :icon, or :none
variant Symbol :inset :sidebar, :floating, or :inset
side Symbol :left :left or :right
css_classes String "" Additional CSS classes
html_options Hash {} Additional HTML attributes

Menu Button

Parameter Type Default Description
title String required Button text
url String required Link URL
icon_name Symbol nil Icon name
size Symbol :default :default, :sm, or :lg
active Boolean false Whether active
css_classes String "" Additional CSS classes
html_options Hash {} Additional HTML attributes

Menu Link

Parameter Type Default Description
url String required Link URL
title String required Primary text
subtitle String nil Secondary text
text_icon String nil Text for avatar
icon String nil Image URL for avatar
active Boolean false Whether active
css_classes String "" Additional CSS classes
html_options Hash {} Additional HTML attributes

Trigger

Parameter Type Default Description
icon_name Symbol :left_panel Icon name for toggle button
css_classes String "" Additional CSS classes
html_options Hash {} Additional HTML attributes

Other Parts

Partial Description
sidebar/header Top section for logo/branding
sidebar/content Scrollable middle section
sidebar/footer Bottom section for user menu
sidebar/group Groups menu items with optional title
sidebar/menu List container for menu items
sidebar/menu_item Individual menu item wrapper
sidebar/menu_badge Count or short label pinned inside a menu item. text: / content: / block
sidebar/menu_action Icon control pinned right inside a menu item. label: (required), url:, icon_name:, show_on_hover:
sidebar/group_action Icon control pinned to the right of a group label. label: (required), url:, icon_name:
sidebar/separator Divider between groups. orientation: (default :horizontal)
sidebar/trigger Toggle button for sidebar
sidebar/inset Main content area wrapper

Helper Methods

Method Description
sidebar_state(cookie_name) Returns :expanded or :collapsed
sidebar_open?(cookie_name) Returns true if expanded
sidebar_closed?(cookie_name) Returns true if collapsed

Accessibility

Turbo Drive

The sidebar controller integrates with Turbo Drive to maintain correct state across navigations:

Stable IDs

The sidebar generates deterministic IDs based on its side: parameter (sidebar-left, sidebar-right) instead of random IDs. This allows idiomorph to match old and new elements across morph renders, preventing the sidebar from being destroyed and recreated.

The provider div also receives a stable ID (sidebar-provider) for the same reason.

If you render multiple sidebars on the same side, pass explicit id: parameters to avoid collisions:

<%= render "components/sidebar/provider", id: "sidebar-main" do %>
  <%= render "components/sidebar", id: "sidebar-nav", side: :left do %>
    ...
  <% end %>
<% end %>

Morph Compatibility

During a Turbo morph, the server-rendered data-sidebar-open-value may carry a stale value (e.g., from a broadcast where the server has no access to the browser cookie). The controller treats the browser cookie as the source of truth:

  1. Before morph updates attributes, the controller sets an internal guard flag.
  2. When idiomorph overwrites data-sidebar-open-value, the Stimulus value callback is skipped — preventing the stale server value from overwriting the cookie.
  3. After morph completes, the controller reads the cookie, reasserts the correct state, and removes the sidebar-loading class that morph re-adds from server HTML.

Turbo Frames

The sidebar works inside Turbo Frames because stable IDs enable clean Stimulus disconnect/reconnect cycles. On reconnection, initialize() re-reads the cookie, so the sidebar always reflects the latest client-side state.

--- URL: https://maquina.app/documentation/components/stats/ Title: Stats ---

Usage

<%= render "components/stats/stats_grid", columns: 4, cards: [
  { title: "Total Revenue", value: "$1,250.00", icon: :dollar, subtitle: "Trending up this month" },
  { title: "New Customers", value: "1,234", icon: :users },
  { title: "Active Accounts", value: "45,678", icon: :check_circle },
  { title: "Growth Rate", value: "4.5%", icon: :chart_bar }
] %>

Examples

Single Card

Cards render standalone too. Use value_classes and icon_classes for color accents — a utility class always wins over the theme default.

<%= render "components/stats/stats_card",
  title: "Open Tickets",
  value: "12",
  icon: :circle_alert,
  icon_classes: "text-amber-500",
  subtitle: "3 urgent" %>

With Action

<%= render "components/stats/stats_grid",
  columns: 3,
  cards: cards,
  action: link_to("View report", reports_path, data: { component: "button", variant: "outline" }),
  action_position: :end %>

API Reference

Stats Grid

Parameter Type Default Description
cards Array [] Hashes of stats_card parameters
columns Integer 3 Grid columns from the sm breakpoint up, 1-6
action String nil Captured HTML rendered beside the grid
action_position Symbol :end :start or :end
css_classes String "" Additional CSS classes
html_options Hash {} Additional HTML attributes, including data

Stats Card

Parameter Type Default Description
title String required Metric label
value String required Metric value
icon Symbol nil Built-in icon name; custom HTML is also accepted
subtitle String nil Secondary line under the value
icon_classes String "" Classes for the icon area, e.g. a color utility
value_classes String "" Classes for the value, e.g. a color utility
css_classes String "" Additional CSS classes
html_options Hash {} Additional HTML attributes, including data
--- URL: https://maquina.app/documentation/components/table/ Title: Table ---

Usage

<%= render "components/table" do %>
  <%= render "components/table/header" do %>
    <%= render "components/table/row" do %>
      <%= render "components/table/head" do %>Name<% end %>
      <%= render "components/table/head" do %>Email<% end %>
      <%= render "components/table/head", css_classes: "text-right" do %>Amount<% end %>
    <% end %>
  <% end %>

  <%= render "components/table/body" do %>
    <% @users.each do |user| %>
      <%= render "components/table/row" do %>
        <%= render "components/table/cell" do %><%= user.name %><% end %>
        <%= render "components/table/cell" do %><%= user.email %><% end %>
        <%= render "components/table/cell", css_classes: "text-right" do %><%= user.amount %><% end %>
      <% end %>
    <% end %>
  <% end %>
<% end %>

Examples

With Footer

<%= render "components/table" do %>
  <%= render "components/table/header" do %>
    <%# ... %>
  <% end %>
  <%= render "components/table/body" do %>
    <%# ... %>
  <% end %>
  <%= render "components/table/footer" do %>
    <%= render "components/table/row" do %>
      <%= render "components/table/cell", colspan: 2 do %>Total<% end %>
      <%= render "components/table/cell", css_classes: "text-right" do %>$750.00<% end %>
    <% end %>
  <% end %>
<% end %>

Selected Row

<%= render "components/table/row", selected: true do %>
  <%= render "components/table/cell" do %>Selected item<% end %>
<% end %>

Bordered Variant

Draws a border around the scroll container.

<%= render "components/table", variant: :bordered do %>
  <%# ... %>
<% end %>

Striped Variant

Alternates row backgrounds on the table itself.

<%= render "components/table", table_variant: :striped do %>
  <%# ... %>
<% end %>

Simple Table Helper

For collection-driven tables, the simple_table helper renders the whole structure from a column definition. Keys can be attribute names, hash keys, or procs.

<%= simple_table @invoices, caption: "Recent invoices", columns: [
  { key: :number, label: "Invoice" },
  { key: :customer, label: "Customer" },
  { key: ->(i) { i.amount.format }, label: "Amount", align: :right }
], row_id: :id, table_variant: :striped %>

API Reference

Every table partial passes unknown keywords through as HTML attributes, so standard table attributes like colspan and rowspan work directly on cells, and id, aria, or data attributes work on any part.

Table

The table renders two elements: a scrollable container div and the table element inside it. The variant parameter styles the container (that is why :bordered lives there), while table_variant styles the table element itself.

Parameter Type Default Description
container Boolean true Wrap in scrollable container
variant Symbol nil Container variant, :bordered draws a border around the scroll container
table_variant Symbol nil Table variant, :striped alternates row backgrounds
css_classes String "" Additional CSS classes
html_options Hash {} Additional HTML attributes

Table Header

Parameter Type Default Description
sticky Boolean false Sticky header on scroll
css_classes String "" Additional CSS classes
html_options Hash {} Additional HTML attributes

Table Row

Parameter Type Default Description
selected Boolean false Highlight as selected
css_classes String "" Additional CSS classes
html_options Hash {} Additional HTML attributes

Table Head

Parameter Type Default Description
text String nil Heading text
content String nil Captured HTML via capture, or use block
scope String "col" Scope attribute for accessibility
css_classes String "" Additional CSS classes
html_options Hash {} Additional HTML attributes

Table Cell

Parameter Type Default Description
text String nil Cell text
content String nil Captured HTML via capture, or use block
css_classes String "" Additional CSS classes
html_options Hash {} Additional HTML attributes, e.g. colspan, rowspan

simple_table Helper

Parameter Type Default Description
collection Enumerable required Objects or hashes to render
columns Array required Hashes with key (attribute, hash key, or proc), label, and optional align (:center, :right; left by default)
caption String nil Table caption
variant Symbol nil Container variant, :bordered
table_variant Symbol nil Table variant, :striped
empty_message String "No data available" Shown when the collection is empty
row_id Symbol nil Method used to build each row id, row-{value}
html_options Hash {} Additional HTML attributes for the table

Table Body / Footer / Caption

Caption also accepts text and content like cell and head.

Parameter Type Default Description
css_classes String "" Additional CSS classes
html_options Hash {} Additional HTML attributes
--- URL: https://maquina.app/documentation/components/theming/ Title: Theming ---

Reshape every component by declaring token values, not override CSS.

Colors have always been CSS variables. As of 0.6.0 so are shape, focus rings, elevation and weight — which is the whole of what used to require override CSS.

The contract: a theme changes values, not selectors. If a theme needs a selector, either you are changing one component’s shape on purpose, or the token layer is missing a token — open an issue.

Upgrading from 0.5.1? Read Upgrading first — it leads with a one-line fix every existing app needs.


Role Tokens

Tokens are named for the role a value plays, not for a size, so controls and surfaces can be shaped independently.

Token Default Applies to
--control-radius 0.375rem Buttons, inputs, selects, textareas, badges, menu items, pagination links, calendar days, sidebar items
--surface-radius 0.5rem Cards, alerts, popovers, toasts, tables, stats, empty, calendar, drawer, the sidebar inset
--mark-radius 4px The checkbox box
--pill-radius calc(infinity * 1px) Radio, switch track
--focus-ring-width 3px Every focus ring
--focus-ring-offset 0px Every focus ring
--focus-ring-style solid Every focus ring
--focus-ring-color see below Every focus ring; invalid fields and destructive buttons override it with the destructive tint
--elevation-control shadow-xs Inputs, selects, textareas, checkbox, radio
--elevation-raised shadow-sm Cards, stats cards, floating sidebar, every filled button
--elevation-overlay shadow-md Dropdown and combobox popovers, the date-picker popover, toasts, the drawer panel
--elevation-none none Ghost and link buttons, the inset sidebar
--label-weight 500 Labels, buttons
--value-weight 700 Stat values
--control-fill transparent Field background; re-set under .dark
--destructive-text var(--destructive-foreground) Field error text ([data-form-part="error"])
--destructive-border var(--destructive) The border on an invalid field

--x is a fill; --x-foreground is the text on that fill

Every -foreground token names the colour that sits on its pair, never text on the page. [data-form-part="error"] is the one place that distinction bites: a field error is body text on a card, so painting it with --destructive-foreground is only correct if your palette happens to define that token as a readable-on-page red.

Both conventions are in the wild, and they are inverses of each other:

Convention --destructive --destructive-foreground
Tinted — what bin/rails g maquina_components:install writes, matching --success / --warning pale tint dark readable red
Saturated — shadcn-style saturated red near-white

So error text routes through --destructive-text, which defaults to --destructive-foreground — correct under the tinted palette, and one line to fix under a saturated one:

:root {
  --destructive-text: var(--destructive);
  --destructive-border: var(--destructive);
}

rake maquina:doctor measures your own tokens against your --card and reports destructive-error-invisible if the error text cannot be read.

--focus-ring-color has no single default

The other three focus tokens are declared once in the engine’s @theme block. --focus-ring-color is declared nowhere: each rule supplies its own default as the var() fallback, because the right resting colour differs by family.

Family Default when you do not set the token
Buttons, cards, badges, toasts, drawer, pagination, calendar, toggle group, date picker var(--ring)
Everything inside the sidebar, and the menu button var(--sidebar-ring, var(--ring))
Form fields — input, textarea, select, checkbox, radio color-mix(in oklch, var(--ring) 50%, transparent)

Setting --focus-ring-color once at :root overrides all three at the same time, which is usually what you want — a declared token means no fallback ever fires. Set it in a narrower scope to keep the families apart.

Two states deliberately outrank a :root override, because a state must win: an aria-invalid field (and anything inside .field_with_errors) and a data-variant="destructive" button declare --focus-ring-color on the element itself. An element’s own custom property beats an inherited one, so those rings stay on the destructive tint whatever :root says.

The invalid field’s border is separate from its ring and reads --destructive-border; its error message reads --destructive-text. Since 0.7.1 the invalid state is keyed on aria-invalid and :user-invalid, so a pristine required field no longer paints it.

Never transition outline-color

If you write your own component against these tokens, keep outline-color out of its transition — and that means not using Tailwind’s transition-colors, which includes outline-color in v4. A transitioned ring animates from its pre-focus value, which on a control that has never painted an outline is the initial currentColor: the control’s own text colour. On a filled variant that is a near-white ring for the first 150ms, which is no focus indicator at all on exactly the controls that matter most. It also makes getComputedStyle read the previous colour if you measure right after a Tab press, which is a reliable way to convince yourself a working ring is broken.

Name the properties instead:

transition-property: color, background-color, border-color, text-decoration-color;

Try it on any demo on this site. Every preview panel now carries a shape button next to the dark-mode toggle. It cycles defaultbrutalsoft, which are nothing but different values for the tokens above — no component selector is involved. Flip it on any component page to watch the token layer move.


Flat Theme in Six Lines

:root {
  --elevation-control: none;
  --elevation-raised: none;
  --elevation-overlay: none;
  --elevation-none: none;
  --control-radius: 0.25rem;
  --surface-radius: 0.25rem;
}

Every shadow in the library disappears and every box takes a 4px corner. The checkbox and the switch keep their own roles, which is the point of separating them.

Brutalist Theme in Twelve Lines

:root {
  --control-radius: 0;
  --surface-radius: 0;
  --mark-radius: 0;
  --pill-radius: 0;
  --focus-ring-width: 4px;
  --focus-ring-offset: 3px;
  --focus-ring-color: var(--foreground);
  --elevation-control: none;
  --elevation-raised: none;
  --elevation-overlay: none;
  --label-weight: 700;
  --value-weight: 900;
}

Square everything, thicken the ring and push it off the edge, drop every shadow, and make labels and values shout. No component selector anywhere.


Where the Declarations Go

Put them in a plain, unlayered :root block in your theme.css — that is what the installer generates, and unlayered CSS wins over the engine’s @theme defaults whatever the import order.

Do not wrap them in @theme: that emits into @layer theme alongside the engine’s own defaults, where source order becomes the only tie-breaker. Do not rename them into Tailwind’s namespaces (--radius-*, --shadow-*) either — a @theme { --radius-*: initial } in an app would wipe them.

/* app/assets/tailwind/theme.css */
:root {
  --surface-radius: 1rem;
}

Recoloring Control Marks

The checkbox tick, the checkbox dash, the radio dot, the switch thumb and the select chevron are whole SVG data URIs rather than a color token, and that is forced by CSS, not a choice: var() cannot be interpolated into url(), a data URI is a separate SVG document so currentColor never resolves inside it, and mask-image would mask the whole element — box, border and shadow — along with the glyph. So each mark is exposed as its own property.

Token Mark
--checkbox-mark-image Checkbox tick
--checkbox-indeterminate-image Checkbox dash
--radio-mark-image Radio dot
--switch-thumb-image Switch thumb
--select-chevron-image Select chevron

They theme like every other token: set one in :root (or in any theme block) and every control picks it up. The engine keeps its own artwork in the use-site fallback rather than declaring it on the control, precisely so that a global declaration wins.

:root {
  --checkbox-mark-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='%23ffffff' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M8 2l1.8 4.2L14 8l-4.2 1.8L8 14l-1.8-4.2L2 8l4.2-1.8z'/%3e%3c/svg%3e");
}

Per-instance opt-in

A light --primary makes the default white ink measure about 1.15:1 against the checked fill. If that is a one-off rather than a theme-wide decision, one attribute fixes it with no CSS at all:

<%= f.check_box :terms, data: { component: "checkbox", mark: "dark" } %>

data-mark="dark" works on the checkbox, radio, switch and select; data-mark="light" is also available on the select. Both are declared on the control, so an explicit per-instance opt-in beats a global default — which is the right way round.

The select chevron carries a different default per color scheme, because gray-500 alone is low-contrast on a dark field. That default is inherited rather than declared on the control, so one :root line still retints it in both schemes. If you want a different ink per scheme, say so explicitly:

:root { --select-chevron-image: url("…dark ink…"); }
.dark { --select-chevron-image: url("…light ink…"); }

Dark Mode

Dark-mode differences are token values too, so you rarely need a .dark twin of a component rule. Set the token inside your own .dark block:

.dark {
  --focus-ring-color: color-mix(in oklch, var(--ring) 70%, transparent);
}

--control-fill is the one to know about: the engine re-declares it under .dark on the fields themselves, so overriding the dark field background needs a selector that reaches the control.

.dark [data-component="input"],
.dark [data-component="textarea"],
.dark [data-component="select"] {
  --control-fill: oklch(0.2 0.03 260);
}

Pinning One Component

Every radius and elevation site also reads a component-level property that falls back to the role token, so you can pin one component without redefining a role. Role tokens are the public API; these exist for the one-off.

:root {
  --card-radius: 0.75rem;   /* cards only; everything else stays 0.5rem */
  --toast-shadow: none;     /* toasts only */
}
Property Falls back to
--button-radius, --input-radius, --textarea-radius, --select-radius, --badge-radius, --pagination-radius, --toggle-group-radius, --date-picker-radius, --menu-button-radius, --sidebar-item-radius, --calendar-cell-radius, --combobox-item-radius, --dropdown-menu-item-radius, --toast-action-radius, --toast-close-radius, --drawer-close-radius --control-radius
--card-radius, --alert-radius, --table-radius, --stats-radius, --empty-radius, --fieldset-radius, --calendar-radius, --combobox-radius, --dropdown-menu-radius, --menu-button-content-radius, --date-picker-popover-radius, --sidebar-radius, --inset-radius, --avatar-radius, --toast-radius --surface-radius
--checkbox-radius --mark-radius
--radio-radius, --switch-radius --pill-radius
--card-shadow, --stats-shadow --elevation-raised
--combobox-shadow, --dropdown-menu-shadow, --menu-button-shadow, --date-picker-popover-shadow, --toast-shadow, --toast-hover-shadow, --drawer-shadow --elevation-overlay

Auditing an Existing Theme

maquina:doctor scans an app’s CSS, views and JavaScript and prints every place that restates something the token layer now owns, plus the one pattern that breaks outright. It never edits anything.

bin/rails maquina:doctor

Each finding names the release it came from, so the report stays useful across upgrades rather than describing one migration. See Upgrading for what changed in each release.


Next Steps

Upgrading to 0.6.0

The preflight shim fix, the scanner, and every breaking change.

Form Components

Where control radius, marks and focus rings show up first.

--- URL: https://maquina.app/documentation/components/toast/ Title: Toast ---

Usage

<%= render "components/toast",
  title: "Scheduled: Catch up",
  description: "Friday, February 10, 2025 at 5:57 PM" %>

Examples

Success

<%= render "components/toast",
  variant: :success,
  title: "Success!",
  description: "Your changes have been saved." %>

Error

<%= render "components/toast",
  variant: :error,
  title: "Error",
  description: "There was a problem with your request." %>

Warning

<%= render "components/toast",
  variant: :warning,
  title: "Warning",
  description: "Your session is about to expire." %>

With Action

<%= render "components/toast",
  title: "Event Created",
  description: "Your event has been scheduled.",
  content: capture { %>
  <%= render "components/toast/action", label: "Undo", href: "#" %>
<% } %>

API Reference

Toast

Parameter Type Default Description
variant Symbol :default :default, :success, :info, :warning, :error; :destructive is accepted as an alias of :error
title String nil Toast title text
description String nil Toast description text
icon Symbol nil Icon name (auto-selected by variant)
duration Integer 5000 Auto-dismiss time in ms
dismissible Boolean true Show close button
content String nil HTML content via capture, or use block (e.g., action buttons)
css_classes String "" Additional CSS classes
html_options Hash {} Additional HTML attributes

Toast Title

Parameter Type Default Description
text String nil Title text
content String nil HTML content via capture, or use block
css_classes String "" Additional CSS classes
html_options Hash {} Additional HTML attributes

Toast Description

Parameter Type Default Description
text String nil Description text
content String nil HTML content via capture, or use block
css_classes String "" Additional CSS classes
html_options Hash {} Additional HTML attributes

Toaster

The toaster is the container that holds and positions toast notifications. Place it once in your layout.

<%= render "components/toaster", position: :bottom_right,
      content: toast_flash_messages %>
Parameter Type Default Description
position Symbol :bottom_right :top_left, :top_right, :bottom_left, :bottom_right
content String nil Pre-rendered toasts (e.g., flash messages)
css_classes String "" Additional CSS classes
html_options Hash {} Additional HTML attributes

Toast Action

Parameter Type Default Description
label String required Button/link text
href String nil Link URL (renders button if nil)
method Symbol nil HTTP method for Turbo
css_classes String "" Additional CSS classes
html_options Hash {} Additional HTML attributes

Helper Methods

Method Description
toast_flash_messages(exclude: []) Renders all flash messages as toasts
toast(variant, title, **options) Renders a single toast
toast_success(title, **options) Shorthand for success variant
toast_error(title, **options) Shorthand for error variant
toast_warning(title, **options) Shorthand for warning variant
toast_info(title, **options) Shorthand for info variant

JavaScript API

The toaster exposes a global Toast object for creating toasts from JavaScript:

Toast.success("Message saved!")
Toast.error("Something went wrong", { description: "Please try again" })
Toast.destructive("Record deleted")   // alias of Toast.error
Toast.info("New update available", { duration: 10000 })
Toast.warning("Session expiring soon")
Toast.show("Custom message", { variant: "default" })
Toast.dismiss(toastId)
Toast.dismissAll()
--- URL: https://maquina.app/documentation/components/toggle-group/ Title: Toggle Group ---

Usage

<%= render "components/toggle_group", type: :single, value: "center" do %>
  <%= render "components/toggle_group/item", value: "left", aria_label: "Align left" do %>
    <%= icon_for :align_left, class: "size-4" %>
  <% end %>
  <%= render "components/toggle_group/item", value: "center", aria_label: "Align center", pressed: true do %>
    <%= icon_for :align_center, class: "size-4" %>
  <% end %>
  <%= render "components/toggle_group/item", value: "right", aria_label: "Align right" do %>
    <%= icon_for :align_right, class: "size-4" %>
  <% end %>
<% end %>

Examples

Multiple Selection

<%= render "components/toggle_group", type: :multiple, value: ["bold", "italic"] do %>
  <%= render "components/toggle_group/item", value: "bold", aria_label: "Bold", pressed: true do %>
    <%= icon_for :bold, class: "size-4" %>
  <% end %>
  <%= render "components/toggle_group/item", value: "italic", aria_label: "Italic", pressed: true do %>
    <%= icon_for :italic, class: "size-4" %>
  <% end %>
  <%= render "components/toggle_group/item", value: "underline", aria_label: "Underline" do %>
    <%= icon_for :underline, class: "size-4" %>
  <% end %>
<% end %>

Outline Variant

<%= render "components/toggle_group", type: :single, variant: :outline do %>
  <%= render "components/toggle_group/item", value: "list", aria_label: "List view" do %>
    <%= icon_for :list, class: "size-4" %>
  <% end %>
  <%= render "components/toggle_group/item", value: "grid", aria_label: "Grid view" do %>
    <%= icon_for :grid, class: "size-4" %>
  <% end %>
<% end %>

With Text Labels

<%= render "components/toggle_group", type: :single, size: :lg do %>
  <%= render "components/toggle_group/item", value: "day", pressed: true do %>
    Day
  <% end %>
  <%= render "components/toggle_group/item", value: "week" do %>
    Week
  <% end %>
  <%= render "components/toggle_group/item", value: "month" do %>
    Month
  <% end %>
<% end %>

API Reference

Toggle Group

Parameter Type Default Description
type Symbol :single :single or :multiple selection
variant Symbol :default :default or :outline
size Symbol :default :sm, :default, :lg
value String/Array nil Initial selected value(s)
disabled Boolean false Disable all items
css_classes String "" Additional CSS classes
html_options Hash {} Additional HTML attributes

Toggle Group Item

Parameter Type Default Description
value String required Value when selected
pressed Boolean false Initial pressed state
disabled Boolean false Disable this item
aria_label String nil Accessible label for icon-only items
css_classes String "" Additional CSS classes
html_options Hash {} Additional HTML attributes

Builder Helper

The toggle_group helper renders the group and its items in one call:

<%= toggle_group type: :multiple, variant: :outline do |group| %>
  <% group.item value: "bold", icon: :bold, aria_label: "Toggle bold" %>
  <% group.item value: "italic", icon: :italic, aria_label: "Toggle italic" %>
<% end %>

Or fully data-driven with toggle_group_simple:

<%= toggle_group_simple type: :single, items: [
  { value: "left", icon: :align_left, aria_label: "Align left" },
  { value: "center", icon: :align_center, aria_label: "Align center" },
  { value: "right", icon: :align_right, aria_label: "Align right" }
] %>
--- URL: https://maquina.app/documentation/components/upgrading/ Title: Upgrading ---

What breaks between releases, and what to do about it.


0.7.0 → 0.7.1

No API changes. Four fixes reported by a consuming app, all of which either correct themselves on upgrade or take one line of theme CSS.

bundle update maquina-components
bin/rails maquina:doctor

The doctor gained five rules for this release, and every finding is now tagged with the release it came from.

A required field with no placeholder no longer paints the error state

The invalid rule for input and textarea was keyed on :invalid:not(:placeholder-shown). That guard only works on a field that has a placeholder: without one :placeholder-shown never matches, so its negation is always true and an empty required field matched :invalid from first paint — red before focus, before blur, before submit, and with no aria-invalid, so the visual and assistive channels disagreed. The date picker had the same shape through a bare input:invalid.

Both now key on :user-invalid, which only matches once the reader has actually interacted with the field. The destructive outline is also gated behind :focus-visible now, rather than painting a permanent halo on a resting field.

If you were relying on the old behaviour — that is, you render server-side errors and never set aria-invalid — the border will stop appearing. Set it explicitly:

<%= f.email_field :email, data: { component: "input" },
    aria: { invalid: @user.errors[:email].any? } %>

The doctor reports this as breaking / invalid-styling-without-aria, and flags remaining required-without-placeholder fields as review / required-without-placeholder.

Field error text has its own colour token

[data-form-part="error"] painted --destructive-foreground, which is the colour meant to sit on a destructive fill — every other use of that token in the engine pairs it with a --destructive background. A field error is text on a card, so under a saturated (shadcn-style) palette it rendered near-white on white and the message was simply not there.

It now reads --destructive-text, defaulting to --destructive-foreground, and the invalid border reads --destructive-border, defaulting to --destructive.

Nothing to do if you use the palette the installer wrote. If your --destructive is a saturated red and --destructive-foreground is near-white, add two lines:

:root {
  --destructive-text: var(--destructive);
  --destructive-border: var(--destructive);
}

See Theming for the full convention. The doctor measures your actual tokens against your --card and reports breaking / destructive-error-invisible when the error text cannot be read. If you worked around this with your own text-destructive utility, it reports cleanup / destructive-error-workaround.

The dropdown menu and menu button flip when they hit the fold

Neither controller measured anything, so a trigger near the bottom of the window opened straight past it — and because the clipped items are the ones at the end of the menu, the destructive action was the first thing to disappear. Both now measure on open and set data-side themselves. The CSS for every side already shipped; nothing was choosing one.

If you carry your own flip controller, you can delete it — the doctor reports it as cleanup / app-level-dropdown-flip.

Every leaf partial accepts a block

Nine leaf partials rendered text || content and silently dropped a block, while nine others accepted one — so render "components/alert" do … end worked and render "components/alert/title" do … end, one line below it, produced an empty element with no error. All eighteen now take text:, content: or a block interchangeably, and text: "" consistently falls through to the block rather than rendering empty in half of them.

This is additive: anything that worked before still works.


0.6.1 → 0.7.0

No breaking changes and nothing to migrate — an accessibility release. One deprecation, and a good deal of host-side code you can now delete.

bundle update maquina-components

What changes on its own

Deprecated: collapse_after

responsive_breadcrumbs(..., collapse_after: 3) still accepts the argument and now ignores it. It existed only to fake collapsing while the measurement was broken, and it collapsed on item count alone — so it also collapsed a trail with plenty of room. Delete it from your calls; it goes away in 0.8.0.

Workarounds you can delete

Several apps carry host-side code for the bugs above. Deleting it is the right outcome, not keeping it:


0.5.1 → 0.6.0

Start here, then run the scanner:

bundle update maquina-components
bin/rails maquina:doctor

maquina:doctor reads your CSS, views and JavaScript and prints file:line for every pattern this release changes, grouped BREAKING / REVIEW / CLEANUP. It never edits anything and never fails a build.


1. Your theme.css Preflight Shim Now Flattens Alert and Toast Borders

This affects every existing app, and it fails silently. The theme.css shipped by earlier installers ends with an unlayered universal rule:

/* 0.5.1 — as installed */
* {
  border-color: var(--color-border);
}

In 0.6.0 the engine’s rules live in @layer components. Unlayered CSS outranks every layer at any specificity, so that one rule now wins over the tinted borders on all alert and toast variants: a destructive alert’s border measures oklch(0.928 0.006 264) — plain --border — where 0.5.1 painted oklch(0.92 0.05 25).

The generator template is fixed, but the rule lives in your file. Wrap it:

/* 0.6.0 — one line of nesting */
@layer base {
  * {
    border-color: var(--color-border);
  }
}

maquina:doctor reports this as breaking / unlayered-universal-rule. The same applies to any other unlayered * rule you have added.


2. Utilities Passed Through css_classes Now Win

Every engine rule is flattened to specificity 0,1,0 and layered, so a Tailwind utility passed as css_classes: finally takes effect. It used to be silently swallowed — which means utilities you already pass may start applying.

<%= render "components/form", css_classes: "flex" do %>
Site 0.5.1 0.6.0
Input with a width utility 448px 137px
Form actions with a hidden utility display: flex display: none
Form with a flex utility display: grid display: flex

Search your views for css_classes: before upgrading. Anything you passed as decoration and never saw is now live; delete what you did not mean.


3. Radius and Elevation Defaults Normalize

Radius now comes from four role tokens. Eight sites move:

Component / part 0.5.1 0.6.0
[data-component="card"] 12px 8px
[data-sidebar-part="inset"] (variant inset) 12px 8px
[data-sidebar-part="inset"] [data-component="header"] top corners 12px 8px
[data-combobox-part="content"] popover 6px 8px
[data-dropdown-menu-part="content"] popover 6px 8px
[data-combobox-part="option"] 4px 6px
[data-dropdown-menu-part="item"] 4px 6px
[data-toast-part="close"] 4px 6px

Four elevation sites collapse from shadow-lg to --elevation-overlay, which resolves to shadow-md: the toast, the toast on hover, the drawer panel and the date-picker popover.

Each site keeps a component-level escape hatch, so any one of them can be pinned without redefining a role. See Theming, or take the whole block from the appendix below.


4. Focus Rings Are Outlines, and Buttons Finally Have Them

Three changes in one:

/* 0.5.1 — a box-shadow ring, on :focus as well as :focus-visible */
[data-component="input"]:focus,
[data-component="input"]:focus-visible {
  box-shadow: 0 0 0 2px var(--background), 0 0 0 4px var(--ring);
}

/* 0.6.0 onward — an outline, keyboard focus only, from tokens.
   Written as longhands since 0.7.0: the shorthand is invalid at
   computed-value time as a unit, so one unresolvable var() took the
   whole ring down and left outline-color: currentColor behind. */
[data-component="input"]:focus-visible {
  outline-width: var(--focus-ring-width);
  outline-style: var(--focus-ring-style);
  outline-color: var(--focus-ring-color);
  outline-offset: var(--focus-ring-offset);
}

If a custom component of yours keys off the engine’s ring, read the tokens instead: --focus-ring-width, --focus-ring-offset, --focus-ring-style, --focus-ring-color.


5. merge_component_data Precedence Narrows

The component used to win every key it set. Now it wins only its identity keys: :component, :variant, :size, and any key ending in _part or -part. :controller and :action still concatenate — the component’s tokens first, then yours. Everything else the caller wins.

<%# 0.5.1: the toast's own state won, this did nothing %>
<%# 0.6.0: renders data-state="exiting" %>
<%= render "components/toast", title: "Saved", data: { state: "exiting" } %>

The merged hash is also .compacted, so a nil value emits no attribute at all where it used to emit an empty one. false still renders "false" — that is a value, not an absence.

Related, and also reported by the doctor as breaking: a sidebar item now omits data-active entirely when it is inactive, instead of writing data-active="false". Presence selectors no longer match:

/* before */ [data-sidebar-part="menu-button"][data-active] { }
/* after  */ [data-sidebar-part="menu-button"][data-active="true"] { }
<!-- before --> <a data-[active]:bg-accent>
<!-- after  --> <a data-[active=true]:bg-accent>

6. Surfaces Above the Page Stop Painting the Page Color

An alert, a calendar and the date-picker popover painted --background — the page. Anything floating above the page is a surface, so they now paint --card or --popover.

If your theme sets those to the same value, nothing moves. That is exactly why this went unnoticed: in the default light theme all three are white. In the default dark theme they separate.

alert, calendar background (dark)   oklch(0.13 0.028 261) → oklch(0.178 0.032 260)

Measured the old way, the calendar sat at ΔL 0.00 against the page — an invisible surface. Related: the outline and ghost buttons and the active pagination link now paint transparent instead of --background, so they work inside a card, which they previously did not.

To pin the old behavior, point the surface tokens at the page:

:root {
  --popover: var(--background);
  --card: var(--background);
}

Checking surface-against-surface contrast? Use ΔL on the CIE L* axis, not a WCAG ratio. WCAG contrast is a text metric; on two adjacent large surfaces it reads a misleading ~1.1 and tells you nothing.


7. Tinted Badges Lose a Stray Hairline

Badge’s success / warning / destructive variants have always set border-color: transparent. The unlayered * shim from step 1 was overriding it with --border, so those badges carried a grey 1px outline they were never meant to have. Once the shim is layered, the intended transparent border shows through.

Nothing to do — but if you had compensated for the hairline elsewhere, remove the compensation.


Appendix: Keeping the 0.5.1 Look

Everything above is a value, so a single token block reverts the visual changes. Drop this into your theme.css and delete the lines you do not want.

:root {
  /* Radius — the eight sites that moved */
  --card-radius: 0.75rem;
  --inset-radius: 0.75rem;
  --combobox-radius: 0.375rem;
  --dropdown-menu-radius: 0.375rem;
  --combobox-item-radius: 0.25rem;
  --dropdown-menu-item-radius: 0.25rem;
  --toast-close-radius: 0.25rem;

  /* Elevation — the four sites that collapsed shadow-lg → shadow-md */
  --toast-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
  --toast-hover-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
  --drawer-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
  --date-picker-popover-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);

  /* Focus ring — the closest outline equivalent of the old two-step ring */
  --focus-ring-width: 2px;
  --focus-ring-offset: 2px;
}

Two things this block cannot bring back, because they are not values:

Running bin/rails generate maquina_components:install again is safe: it is idempotent, appends the shape/state token block only once, and never rewrites your palette.


Next Steps

Theming

The full token table, ready-made themes, and pinning a single component.

Components Overview

Installation, setup, and the full component index.

--- URL: https://maquina.app/documentation/engines/ Title: Engines ---

Mountable Rails engines that add complete features to your app. Mount under a backstage path, configure a few options, and get production-ready functionality themed with maquina_components.


Available Engines

Maquina Newsletters

Draft, approve, schedule, and batch-send HTML newsletters from a backstage area.


What Is a Mountable Engine?

A Rails engine is a miniature application that plugs into a host app. You mount it at a path in config/routes.rb, run its installer, and it brings its own models, controllers, views, and background jobs — while reusing your app’s database, authentication, and configuration.

# config/routes.rb
mount MaquinaNewsletters::Engine => "/backstage/newsletters"

Maquina engines keep authentication in the host app’s hands (they inherit from a base controller you configure) and theme their UI with maquina_components, so they look like a native part of your app.

--- URL: https://maquina.app/documentation/tools/mongo-menu/ Title: Mongo Menu ---

A macOS menu bar application that makes managing local MongoDB instances simple and convenient. Start, stop, and configure MongoDB with a single click.

Mongo Menu in the menu bar Mongo Menu Preferences

Overview

Mongo Menu sits in your macOS menu bar, providing easy access to start, stop, and configure MongoDB instances. Designed for developers who need to work with MongoDB locally and want a straightforward way to manage the database without terminal commands.


Features


Requirements


Installation

Build from Source

  1. Clone the repository:

    git clone https://github.com/maquina-app/mongo-menu.git
    cd mongo-menu
    
  2. Run the build script:

    ./build.sh
    

    The build script will:

  3. Move the built app to your Applications folder

Build Requirements


Usage

  1. Click the MongoDB icon in the menu bar to see status and control options
  2. Use “Start MongoDB” or “Stop MongoDB” to control the service
  3. Click “Preferences” to configure settings:

Configuration

Default Locations

Mongo Menu stores data in these default locations:

Setting Default Path
Data directory ~/.local/share/mongodb/data
Log file ~/.local/state/mongodb/logs/mongodb.log
Port 27017

All locations can be customized in the app preferences.

Bundled MongoDB

Mongo Menu bundles MongoDB binaries (version 8.0.6) specifically for Apple Silicon Macs. You don’t need to install MongoDB separately. The app handles downloading the appropriate MongoDB binaries for your Mac.


Troubleshooting

MongoDB Won’t Start

App Won’t Launch


Next Steps

GitHub Repository

Source code, issues, and contribution guidelines.

Redis Menu

Similar menu bar app for managing Redis instances.

--- URL: https://maquina.app/documentation/tools/ Title: Tools ---

Developer utilities for your local environment. Menu bar apps for managing databases, CLI tools for workflow automation, and helpers that make development easier.


Available Tools

equipr

Install skills, commands, and MCP servers into your coding agents.

Redis Menu

macOS menu bar app for managing local Redis instances.

Mongo Menu

macOS menu bar app for managing local MongoDB instances.

Git Continuity

Transfer work-in-progress between machines without commits.


Redis Menu and Mongo Menu are native macOS applications that sit in your menu bar. They provide:

Requirements

Both menu bar apps require:


CLI Tools

equipr

Install skills, commands, and MCP servers from marketplaces and Agent Plugins sources into your coding agents: Claude Code, Codex CLI, OpenCode, and Pi.

equipr add https://github.com/coreyhaines31/marketingskills
equipr install marketingskills/marketing-skills:seo-audit

equipr places each component where each agent expects it, and merges MCP entries into your existing agent config a single key at a time, leaving your hand-edits intact.

View equipr Documentation

Git Continuity

Transfer work-in-progress between machines without committing to git history. Perfect for moving unfinished work between office and home.

# On your work machine
git continuity push

# On your home machine
git continuity pull

Your uncommitted changes, staged files, and untracked files are transferred without polluting your git history.

--- URL: https://maquina.app/documentation/tools/equipr/ Title: equipr: skills and MCP servers for coding agents ---

Install skills, commands, and MCP servers from marketplaces and Agent Plugins (AP) sources into your coding agents: Claude Code, Codex CLI, OpenCode, and Pi.

Current version: 0.4.0, which is what this page documents. equipr is pre-1.0, so the surface is still settling.


What Is This?

A single Go binary that:

The more interesting part is what equipr refuses to do.

It never registers itself as a native plugin in any agent. No /plugin, no global npm install, nothing that turns up in an agent’s own plugin list; it writes to personal and global config surfaces and stops there. It also never touches a config key it does not own. Every write into a shared agent config is a targeted single-key merge, which is why your hand-edits, your comments, and your key ordering all survive it.

Why It Exists

Skills and MCP servers are distributed as git repositories, npm packages, and archives, but every coding agent expects them in a different place, in a different shape. Claude Code reads ~/.claude/skills/. OpenCode reads ~/.config/opencode/skills/ and uses a different MCP config schema. The manual answer is copying directories around and hand-editing shared JSON and TOML config files that also hold your credentials.

Doing that by hand is fine once. It stops being fine at four agents, a dozen skills, and a config file you have already customized.


Quick Start

1. Install

brew install maquina-app/tap/equipr

2. Add a source

$ equipr add https://github.com/coreyhaines31/marketingskills
Fetching https://github.com/coreyhaines31/marketingskills
Added marketingskills (marketplace, fetched via git) with 1 plugin(s)
  - marketing-skills 2.10.0

3. See what it holds

$ equipr list
marketingskills     marketplace
  marketing-skills  2.10.0  49 skills

4. Install a component into your agents

$ equipr install marketingskills/marketing-skills:seo-audit --yes
Installed 1 component(s) into 3 agent(s):
  [claude-code] seo-audit (copy -> ~/.claude/skills/seo-audit)
  [opencode] seo-audit (symlink -> ~/.config/opencode/skills/seo-audit)
  [pi] seo-audit (symlink -> ~/.pi/agent/skills/seo-audit)

Three agents rather than four, because Codex was not installed on the machine this run was captured on.

Without --yes, install is interactive: pick agents, then pick components. Components all start checked, and so do the agents whose binary is on $PATH, so pressing enter through both installs everything into every agent you actually have.

5. Check your installs

$ equipr status
STATE  COMPONENT     SOURCE/PLUGIN                     AGENT        MECH     TARGET
ok     seo-audit(s)  marketingskills/marketing-skills  claude-code  copy     ~/.claude/skills/seo-audit
ok     seo-audit(s)  marketingskills/marketing-skills  opencode     symlink  ~/.config/opencode/skills/seo-audit
ok     seo-audit(s)  marketingskills/marketing-skills  pi           symlink  ~/.pi/agent/skills/seo-audit

$ equipr doctor
No issues found.

Installation

Every release publishes binaries for darwin, linux, and windows on amd64 and arm64. macOS binaries are ad-hoc codesigned, because an unsigned Mach-O binary is killed on Apple Silicon.

# Homebrew (macOS and Linux)
brew install maquina-app/tap/equipr

# Install script: /usr/local/bin when that is writable, otherwise ~/.local/bin
curl -fsSL https://github.com/maquina-app/equipr/releases/latest/download/install.sh | sh

# From source (Go 1.24+), into any directory on your PATH
go build -o ~/.local/bin/equipr ./cmd/equipr

go install ./cmd/equipr works too, but it puts the binary in $(go env GOPATH)/bin, which is not on everyone’s PATH.

Debian and RPM packages and raw archives are on the releases page.

Verify the install:

$ equipr --version
equipr version 0.3.3

equipr doctor reports on the health of your installs. It says nothing about the binary itself, and with nothing installed yet it prints No issues found. and exits 0.


Concepts

Term What it means
origin Where content comes from, as you type it into add. Auto-detected as git, npm, archive, or local path.
source One added origin, fetched into the cache and recorded in the registry. Either a marketplace or a single AP package.
source-id The short handle a source is addressed by, derived from the origin, usually the repository or directory name.
plugin One installable unit inside a source. An AP source has exactly one; a marketplace can have many.
component One installable thing inside a plugin: a skill, a command, or an MCP server.
agent An install target: claude-code, codex, opencode, or pi.
mechanism How a component is placed: copy or symlink.

Addressing Grammar

Everything nests, and every command addresses one of these three levels:

<source-id>                       marketingskills
<source-id>/<plugin>              marketingskills/marketing-skills
<source-id>/<plugin>:<component>  marketingskills/marketing-skills:seo-audit

Source Types

Type Detected by Contains
Marketplace .claude-plugin/marketplace.json at the root Many plugins, in subdirectories
Agent Plugins package plugin.json at the root Exactly one plugin

Origin Kinds

There are three kinds: git, npm, and archive. They are resolved in a fixed order, and the order does more work than the individual rules:

  1. An explicit npm: prefix wins immediately: npm
  2. Anything ending in .zip, .tar.gz, or .tgz: archive. The test is on the string suffix alone, so a local ./pkg.zip classifies here too. It is checked before git, which is why https://host/pkg.zip resolves as an archive
  3. Contains ://, starts with git@, or ends in .git: git
  4. Exists on disk: npm if it is a directory holding package.json and no .git, otherwise git. Any existing path qualifies, including a plain file
  5. Nothing above matched and nothing exists at that path: npm. This is the fallback, and it is how a bare package name like express resolves

A local path is not a fourth kind. Steps 2 and 4 sort it into one of the three, and step 4 holds a trap: if a directory named express happens to exist in your working directory, equipr add express takes it as that directory rather than the npm package. Write npm:express to force the package.

An origin can also name a subdirectory, either as a fragment or as a forge tree URL:

equipr add https://github.com/owner/repo#plugins/foo
equipr add https://github.com/owner/repo/tree/main/plugins/foo

The /tree/<ref>/<path> form is http(s) only, and equipr clones the default branch shallowly. A tree URL naming some other branch is reported back to you with the # form as the way forward, rather than being quietly resolved against the default branch.

Component Types

Type Where it lives in a plugin
skill A directory under skills/ holding a SKILL.md
command A .md file under commands/
MCP server An entry in the plugin’s mcp.json

Commands are normalized to <name>/SKILL.md for every agent, so a plugin’s commands and skills land in the same place and are discovered the same way.


Commands

Command Purpose
equipr add <origin> Fetch an origin, resolve its contents, register it
equipr list [source-id \| source-id/plugin] List the registry: sources, their plugins, and what each holds
equipr show <source-id>[/plugin] Details for a source or one plugin
equipr install <source>/<plugin>[:<component>] Place a plugin’s components into agents
equipr update Re-fetch every source that has installs and re-apply each recorded component
equipr remove <source-id> Drop a source from the registry
equipr uninstall <source>/<plugin>[:<component>] Remove installed files and records
equipr doctor Read-only health check
equipr status Read-only report of every recorded install

add

Re-running add on a source that already exists refreshes it. Git sources pull; npm and archive sources are re-fetched.

equipr add https://github.com/coreyhaines31/marketingskills

list

-c / --components expands each plugin to its component names. Passing <source>/<plugin> prints what show prints.

equipr list marketingskills --components

install

Interactive by default: pick agents, then pick components. Components start checked; agents start checked when their binary is on $PATH (see Detection). Use -a to pin targets and --yes to skip the prompts.

equipr install marketingskills/marketing-skills:seo-audit --yes -a claude-code

update

Takes no arguments. It operates on every source that has installs.

equipr update --dry-run

--dry-run still re-fetches, so it can report “would update v1 to v2”, but it writes nothing and never prompts.

remove and uninstall

The two are deliberately separate:

equipr uninstall marketingskills/marketing-skills:seo-audit
equipr remove marketingskills --purge

doctor

Read-only. Reports four conditions: orphaned-record, vanished-config-dir, broken-symlink, and modified-cache. It exits 5 when it finds issues and 0 when clean, so scripts checking $? should expect that.

status

A report of every recorded install: state (ok, modified, or missing), component, source and plugin, agent, mechanism, and target path. Writes nothing.


Flags

Persistent

One flag is registered on the root command and applies everywhere.

Flag Effect
-y, --yes Assume yes to all prompts: install into every present agent, install all components, resolve conflicts as overwrite

--json is not persistent, despite looking like it should be. It is registered on each of the nine commands that produce results, and completion and help never register it. So equipr --json on its own is an error, and so is equipr completion zsh --json.

Per Command

Command Flag Values Default
install -a, --agent (repeatable) claude-code, codex, opencode, pi Prompt
install -c, --component A component name, the same thing as the :name suffix Prompt (all with --yes)
install --mechanism copy, symlink The per-agent default
install --on-conflict prompt, overwrite, skip, fail prompt
update --on-conflict prompt, overwrite, skip, fail prompt
update --dry-run Report only, write nothing Off
remove --purge Also delete records, files, and cache Off
list -c, --components Expand plugins to component names Off

Choosing a Mechanism

Mechanism Pick it when
symlink You want the agent to track the cache, so update is instant and the source stays the single copy on disk
copy You want the installed version pinned and independent of the cache, or the agent does not reliably follow links

The per-agent defaults exist for a reason: whole-directory symlinks proved unreliable for Claude Code and Codex, while OpenCode and Pi dereference links at any depth. Symlinks are always created per component, never for a whole directory.

Choosing a Conflict Policy

--on-conflict applies when a target has been locally modified since it was installed.

Value Behavior Use it for
prompt Ask per file Interactive use; the default
overwrite Discard local edits When the source is the truth
skip Keep local edits and move on When you have deliberately customized a skill
fail Abort the whole run on the first modification Scripts and CI, where a surprise should stop the pipeline

Exit Codes

Stable, and safe to script against.

Code Meaning
0 Success
1 Generic or usage error
2 Not found
3 Conflict
4 Environment error
5 doctor found issues

Agents

Agent Detected by Components land in MCP config
claude-code ~/.claude/ or claude on $PATH ~/.claude/skills/<name>/ ~/.claude.json
codex ~/.codex/ or codex on $PATH ~/.codex/skills/<name>/ ~/.codex/config.toml
opencode ~/.config/opencode/ or opencode on $PATH ~/.config/opencode/skills/<name>/ ~/.config/opencode/opencode.json
pi ~/.pi/agent/ or pi on $PATH ~/.pi/agent/skills/<name>/ ~/.config/mcp/mcp.json

Default mechanism: copy for claude-code and codex, symlink for opencode and pi.

Detection

An agent is offered when either signal holds: a config directory, or a binary on $PATH. Both are needed, because a freshly installed agent has no config directory until its first run, and a config directory outlives an uninstalled agent. The picker labels each agent with the evidence it found (configured, on PATH, or both), so a surprising entry explains itself.

Being offered and being selected are different things. Since 0.4.0 the picker pre-checks only the agents whose binary is on $PATH. A config-only agent is listed but starts unchecked, because a leftover config directory outlives an uninstalled agent and enter should not push skills into a directory nothing reads. The exception: when nothing at all is on $PATH, every row starts checked, so that enter is never a silent no-op. --yes is unaffected and still installs into every agent present, config-only ones included.

MCP Server Writes

MCP servers are never written as a whole file. equipr merges a single key into the agent’s existing config, atomically. It writes to a temporary file, then renames it, preserving the file mode. JSON goes through sjson and gjson, TOML through a dedicated writer, specifically so key order, formatting, and comments in a file you hand-edited survive the write.

Agent schemas differ, and equipr writes each in its native shape. OpenCode uses an mcp key with an explicit type and command as an array, where Claude Code uses mcpServers with string inference.


Files and State

Path Holds
$XDG_CONFIG_HOME/equipr/config.toml Reserved; nothing reads it yet
$XDG_STATE_HOME/equipr/sources.json The registry: every added source and what is in it
$XDG_STATE_HOME/equipr/installs.json Install records: component, agent, target path, content hash
$XDG_STATE_HOME/equipr/equipr.lock Lock file, held across mutating operations
$XDG_CACHE_HOME/equipr/sources/<id>/ The fetched tree, per source

On macOS those XDG defaults resolve under ~/Library/Application Support/equipr and ~/Library/Caches/equipr. On Linux, ~/.local/state/equipr and ~/.cache/equipr.

Hand-Editing

Use the commands, not an editor. sources.json and installs.json are rewritten wholesale under the lock, and installs.json records a content hash per target that the conflict check compares against, so editing it by hand makes status and doctor report the wrong thing.

Deleting the cache directory is the one safe destructive act: add or update re-fetches it.


JSON Output

Nine commands accept --json: add, list, show, install, update, remove, uninstall, doctor, and status. Results go to stdout and progress to stderr, so stdout stays parseable.

Script against --json, not the plain-text output. The text layout is still moving: list was restructured in 0.3.2 and status in 0.3.3, both on the same day.

equipr list --json, trimmed. The real skills array holds 49 entries:

{
  "sources": [
    {
      "id": "marketingskills",
      "type": "marketplace",
      "kind": "git",
      "origin": "https://github.com/coreyhaines31/marketingskills",
      "plugin_count": 1,
      "plugins": [
        {
          "name": "marketing-skills",
          "version": "2.10.0",
          "description": "Marketing skills for AI agents — conversion optimization, copywriting, SEO, paid ads, ad creative, and growth",
          "author": "Corey Haines",
          "skills": ["ab-testing", "ad-creative", "ads", "ai-seo", "..."]
        }
      ]
    }
  ]
}

equipr status --json, trimmed to one of three entries:

{
  "entries": [
    {
      "source_id": "marketingskills",
      "plugin": "marketing-skills",
      "component": "seo-audit",
      "component_type": "skill",
      "agent": "claude-code",
      "mechanism": "copy",
      "target": "~/.claude/skills/seo-audit",
      "version": "2.10.0",
      "state": "ok"
    }
  ]
}

Shell Completion

Cobra-generated completion is available for four shells: bash, zsh, fish, and powershell.

equipr completion zsh > "${fpath[1]}/_equipr"     # then restart your shell
equipr completion bash > /etc/bash_completion.d/equipr
equipr completion fish > ~/.config/fish/completions/equipr.fish

equipr completion <shell> --help prints the install instructions for that shell.

Completion covers commands and flags. Source, plugin, and component names are resolved at runtime, so they are not completed.


Recent Changes

Version Change
0.4.0 Local paths that are neither a git repository nor an npm package are rejected up front with a message naming the problem, instead of failing later as a git error. The install picker pre-checks only agents found on $PATH
0.3.4 MIT license added. No change to commands, flags, output, or JSON
0.3.3 status columns sized from the rows
0.3.2 list nests plugins under sources with component counts, gained --components, and accepts <source>/<plugin>
0.3.1 Install prompts pre-check every option; agents detected by config directory or binary on $PATH; skills/ and commands/ walked recursively; marketplace entries rooted at "./" resolve

Before 0.3.1, install prompts started with nothing selected, so pressing enter installed nothing.


Troubleshooting

Nothing was installed

The picker toggles with space and submits with enter. Components start checked, so enter installs all of them. Deselect everything and press enter, though, and nothing installs; equipr says as much. Two other ways to end up with nothing: on 0.3.0 and earlier every row started unselected and the error wrongly claimed the plugin had no components, so upgrade or pass --yes; and since 0.4.0 a config-only agent starts unchecked, so an install can succeed while skipping an agent you expected it to reach.

An agent you do not use is offered

A leftover config directory counts as configured, even with no binary installed. Check the label in the picker: configured on its own means no binary was found on $PATH, and since 0.4.0 that row starts unchecked. Delete the stale directory, or pin your targets with -a. Note that --yes ignores the distinction and installs into every agent present.

A plugin shows no components

Most often it ships only an agents/ directory, holding Claude Code subagents, which equipr does not model yet. equipr handles skills, commands, and MCP servers. That is a known gap in equipr; the source itself is fine.

Adding a local path is rejected

Origin classification resolves any existing path it does not recognize to git, so a directory that is not a git repository, a plain file, and a local archive are each rejected before anything is fetched, with a message naming the actual problem:

$ equipr add ./plaindir
equipr: ./plaindir is neither a git repository nor an npm package directory; a local
source must be one or the other (run `git init` there, or point equipr at a remote origin)

$ equipr add ./notes.txt
equipr: ./notes.txt is a file, not a source; add the directory that holds plugin.json
or .claude-plugin/marketplace.json

$ equipr add ./pkg.zip
equipr: ./pkg.zip is a local archive; equipr fetches archives over http(s) only, so
extract it and add the directory instead

Exit code 1 in all three cases. Before 0.4.0 the first of these surfaced as a raw git clone: repository does not exist, which read like a bug in equipr and was not one.

A marketplace reports fewer plugins than it has

An entry whose source is a remote reference equipr cannot resolve in-tree (a github, npm, or archive object form) is skipped and named in the output, rather than failing the whole marketplace. The fix belongs upstream in the marketplace.

doctor reports modified-cache

The cached tree no longer matches the digest recorded when it was added, which usually means something outside equipr touched the cache. Run equipr add <origin> to re-fetch and clear it.

doctor exits 5

Exit code 5 is documented behavior: doctor found something. Nothing crashed.

Records survive remove

By design. remove deregisters the source but leaves files and records so uninstall can still clean them, and doctor reports them as orphaned-record. Use remove --purge for the one-shot version.

Two components with the same name fail the whole source

Components are named by their own directory or file, which is not unique across a nested tree, and skills and commands share one flat install namespace. Rather than silently overwriting one with the other, add fails and names both paths. The fix belongs upstream in the source.


License

MIT. Copyright (c) 2026 Mario Alberto Chávez.


Next Steps

GitHub Repository

Source code, releases, issues, and contribution guidelines.

Announcement

Why equipr exists, and what it refuses to do.

AI Tools

MCP servers and Claude Code plugins, the kind of thing equipr installs.

Agent Plugins Specification

The plugin format equipr resolves from a source.

Agent Skills Specification

The skill format behind every SKILL.md equipr installs.

--- URL: https://maquina.app/documentation/nexo/workflows/ Title: Workflows ---

An agent accumulates context — it keeps a conversation going. A workflow fires and finishes: a finite job with a stable runId, a status, a payload, a result, and an ordered, inspectable event log. Subclass Nexo::Workflow, implement #call(payload), and run it.


Lifecycle

require "nexo"

class SummarizeDocument < Nexo::Workflow
  def call(payload)
    emit(:started, doc_id: payload[:doc_id])
    summary = payload[:text].to_s.slice(0, 280)   # pure Ruby — no Agent needed
    emit(:summarized, length: summary.length)
    { summary: summary }
  end
end

run = SummarizeDocument.run(doc_id: 123, text: "Long text…")
run.id      # => "0191d6b2-…"  (UUID v7 string, time-ordered)
run.status  # => "done"
run.result  # => { "summary" => "Long text…" }

#call receives a symbol-keyed payload; the stored payload and result read back string-keyed (they survive a JSON round-trip identically whether the run lives in memory or in the database).

Failure model — workflows re-raise

A workflow that raises is recorded as failed with the error message and the exception still propagates to your caller:

run = BoomWorkflow.run     # raises — but the run is persisted as failed first
# => RuntimeError: kaboom

This is deliberately the opposite of a Nexo tool failure, which returns { error: … } and never raises into the agent loop. A tool error is recoverable context for the model; a workflow failure is a job that did not complete. By default a failed run is not retried — the exception is yours to handle. Runs orphaned in "running" by a crashed worker are swept to "interrupted" by reconcile_interrupted!.


The event log — emit and nexo logs

emit(:type, data) appends an ordered event (type, data, at) and persists it incrementally. Inspect a run’s log in plain Ruby:

Nexo::Workflow.logs(run.id) { |ev| puts "#{ev["at"]} #{ev["type"]}" }

or, in a Rails app, from the terminal:

$ bundle exec rake "nexo:logs[0191d6b2-7c4a-7e1f-9a3b-2f5c8d1e6b00]"
[2026-06-29T14:02:01Z] started      {"doc_id"=>123}
[2026-06-29T14:02:01Z] summarized   {"length"=>280}

With or without Rails

With no Rails loaded, runs record to an in-memory store — workflows run, emit, and Nexo::Workflow.logs works, all offline with no database. In a Rails app, install the migration and runs persist to a nexo_workflow_runs table:

rails g nexo:workflows
rails db:migrate

The same Workflow code drives either backend; Nexo::RunStore.default selects ActiveRecord when it is available and the in-memory store otherwise. The schema uses portable json columns (SQLite and PostgreSQL alike) and a UUID string primary key.


Input staging and artifacts

A run owns a sandbox — declared with the sandbox class macro (default :virtual; :local for the host filesystem rooted at the cwd macro, default Dir.pwd). It is resolved lazily: a data-only workflow that never touches files builds nothing. A Workflow accepts the same sandbox forms as an Agent — they share one resolver (Nexo::Sandboxes.resolve), so the two can’t drift, including a hardened container:

class BuildInContainer < Nexo::Workflow
  sandbox :docker, image: "node:22-slim"   # or :apple, or { type: :docker, ... }
  def call(_payload) = { ok: true }
end

stage(files) writes provided inputs into that sandbox before your #call work begins. It takes either a { "path" => "content" } hash or an array of { path:, content: } hashes, emits a :staged event with the count, and returns the count staged.

artifact(name, content:) records a named deliverable on the run — a digest, a report, an improved file, a generated script. The body is written to the sandbox at /artifacts/<name> (so later steps can read it) and recorded on the run. run.artifacts reads it back as an ordered array of string-keyed hashes ({"name" =>, "content" =>, "at" =>}):

class BuildDigest < Nexo::Workflow
  def call(payload)
    stage(payload[:files])                       # baseline + extras into the sandbox
    artifact("digest.md", content: summarize(sandbox.read("/workspace/baseline.md")))
    { ok: true }
  end
end

run = BuildDigest.run(files: [{ path: "baseline.md", content: "…" }])
run.artifacts.first["name"]     # => "digest.md"
run.artifacts.first["content"]  # => "…the digest body…"

You can also render an artifact from a template you control with from: — no templating engine, just stdlib ERB:

# from: is a real disk file when it exists, else a staged sandbox path.
artifact("digest.md", from: "app/templates/digest.md.erb",
         locals: { title: "Weekly", baseline: sandbox.read("/workspace/baseline.md") })

Templates are code, not data. ERB executes arbitrary Ruby. A template passed to artifact(from:) must be a trusted, developer-authored file — never model output or user-uploaded content. Rendering a model-generated or uploaded template is remote code execution. If a body is untrusted, pass it as content: (inert data), not as a from: template.

The artifacts column ships with fresh installs. Apps installed before this release add it with:

rails g nexo:artifacts
rails db:migrate

Live example

The full offline artifact-from-template flow is runnable in the repo:

ruby -Ilib examples/artifact_from_template.rb

View examples/artifact_from_template.rb on GitHub →


Tasks & Actions — drive an agent

A workflow can declare and drive an agent so the two primitives Nexo owns — a Workflow (the run lifecycle) and an Agent (the skilled, sandbox-backed model loop) — compose into one recipe: stage inputs → run the agent → capture artifacts. The agent class macro names the Agent subclass this workflow drives; run_agent(prompt, max_turns: 25) runs it bound to the run’s own sandbox, forwards every tool call/result and the final response into the run log as agent_* events, and closes the agent afterward.

class ReviewBaseline < Nexo::Workflow
  agent CodeReviewer            # the Agent subclass this workflow drives

  def call(payload)
    stage(payload[:files])                          # inputs into the run's sandbox
    resp = run_agent("Review the staged baseline and report OK or the issues.")
    artifact("review.md", content: resp.content)    # capture the agent's output
    { content: resp.content }
  end
end

A driven run reads as one coherent story — Nexo::Workflow.logs(run.id) (and nexo:logs) interleaves the workflow’s own events with the agent’s:

[…] staged            {"count"=>1}
[…] agent_tool_call   {"name"=>"read_file", "args"=>{"path"=>"/workspace/baseline.md"}}
[…] agent_tool_result {"ok"=>true, "content"=>"…"}
[…] agent_done        {"content"=>"REVIEW OK"}

The same workflow runs two ways with no code difference — Nexo stays schedulable, never a scheduler.

As a scheduled Task — invoke it from a background job (the scheduling itself lives in the host):

class ReviewBaselineJob < ApplicationJob
  def perform(files:)
    ReviewBaseline.run(files: files)   # same run entry point
  end
end

# scheduled elsewhere in the host — Nexo does not schedule:
ReviewBaselineJob.perform_later(files: nightly_baseline)

As an interactive Action — invoke the same run from a controller after staging the uploaded files:

class ReviewsController < ApplicationController
  def create
    files = params[:files].map { |f| { path: f.original_filename, content: f.read } }
    run = ReviewBaseline.run(files: files)   # identical call — no code difference
    redirect_to review_path(run.id)
  end
end

Shared-sandbox precedence. Under run_agent the agent uses the workflow’s sandbox; the agent’s own sandbox class macro is ignored (it only applies when the agent runs standalone). The agent keeps its own permissions, skills, mcp, and mcp_allow: the workflow provides the where (sandbox), the agent owns the what (permissions) and the how (skills/instructions). Driving an agent never widens its authority — its safe default (:read_only) is untouched.

Live example

A live example wraps an MCP-backed agent in a workflow and captures the digest as an artifact:

View examples/inbox_digest_task.rb on GitHub →


Reconciling interrupted runs

A crashed worker leaves runs stuck in "running". Nexo::Workflow.reconcile_interrupted! is a one-shot boot/deploy sweep that rewrites only "running""interrupted" (never touching "done" or "failed") and returns the count. It is never auto-invoked — call it from a boot hook or the shipped rake task:

bundle exec rake nexo:reconcile

This is not a liveness check. It cannot tell a genuinely-running run in another process from an orphaned one — so run it once at boot, before any worker starts new runs, not while workers are live.


Where to next


Next steps

Durable workflows

Pause a run durably and resume it later without redoing work.

Rails

Run the same workflow in the background with run_later.

--- URL: https://maquina.app/documentation/nexo/web/ Title: Web ---

Two tools give an agent safe, default-denied web access. Nexo::Tools::Fetch reads a URL with a stdlib HTTP(S) GET, gated by a :fetch capability and a host allow-list. Nexo::Tools::WebSearch discovers URLs, gated by a :search capability and a host-injected backend. They pair: search finds URLs, fetch reads one.

Safe by default: :fetch and :search are denied under :read_only exactly like :shell. Web egress is an escalation, not a “read”. A default agent that never calls fetch_allow / search_backend gets no web tool at all.

Both tools run in the host process (stdlib net/http / a host-injected backend), so no sandbox constrains them — not even a --network none container. They are bounded only by the capability gate plus fetch_allow / the injected backend.


The fetch tool

require "nexo"

class NewsSummary < Nexo::Agent
  model ENV.fetch("NEXO_MODEL")

  # :fetch is DEFAULT-DENIED (like :shell). Grant it explicitly, then scope hosts tightly.
  permissions Nexo::Permissions.new(mode: :read_only, allow: %i[read glob fetch])
  fetch_allow %w[lite.cnn.com text.npr.org hnrss.org]

  skills :news_summary   # teaches WHICH sites to read and HOW to summarize
end

Two independent locks must both open before a byte leaves the process:

  1. The :fetch capability — a first-class capability, denied under :read_only exactly like :shell. You grant it with :auto, or an explicit Permissions.new(mode: :read_only, allow: %i[read glob fetch]).
  2. The fetch_allow host list — scopes which hosts the tool may reach. Matching is subdomain-aware, never a glob: fetch_allow %w[example.com] permits example.com and news.example.com, but refuses notexample.com and example.com.evil.org. Declaring fetch_allow alone does not grant :fetch — it only scopes hosts.

On any denial or error the tool returns { error: … } (recoverable) and never raises into the loop — identical to the sandbox tools. Success returns { body: <raw page, truncated to 200 KB> }.

Security — read before allow-listing a host

Web egress is a real attack surface. Tools::Fetch is deliberately narrow, but you own the allow-list:

JS-heavy pages — use an MCP fetch server instead

Tools::Fetch reads static HTML; it does not render JavaScript. For JS-heavy pages, compose an MCP fetch/browser server instead — it runs its own headless renderer and Nexo gates it through the separate MCP axis:

class BrowseAgent < Nexo::Agent
  model ENV.fetch("NEXO_MODEL")
  mcp :fetch, transport: :stdio, command: "npx", args: %w[-y @modelcontextprotocol/server-fetch]
  mcp_allow %w[fetch]
end

webmock is a dev/test-only dependency (the offline suite stubs all HTTP); it is not a runtime dependency — Tools::Fetch uses only stdlib.

Live example

A live example demonstrates read-only web fetch scoped by fetch_allow:

NEXO_LIVE=1 NEXO_MODEL=… ruby -Ilib examples/news_summary.rb

View examples/news_summary.rb on GitHub →


Web search — the search tool

Nexo::Tools::WebSearch gives an agent a vendor-neutral way to discover URLs. It authorizes a new, default-denied :search capability, then delegates the query to a host-injected backend and returns normalized, capped results. Nexo ships no search provider — you inject the backend.

require "nexo"

class ResearchAgent < Nexo::Agent
  model ENV.fetch("NEXO_MODEL")

  # :search is DEFAULT-DENIED (like :fetch/:shell). Grant it explicitly.
  permissions Nexo::Permissions.new(mode: :read_only, allow: %i[read glob fetch search])
  fetch_allow    %w[lite.cnn.com text.npr.org]
  search_backend MyBraveAdapter.new(ENV.fetch("BRAVE_API_KEY")) # host-owned; Nexo ships none
end

Two things must both be true before the tool runs:

  1. The :search capability — a first-class capability, denied under :read_only exactly like :fetch/:shell. Grant it with :auto, or an explicit Permissions.new(mode: :read_only, allow: %i[read glob search]).
  2. A declared search_backend — the injected provider. A default agent that never calls search_backend gets no search tool at all; existing agents are byte-for-byte unchanged.

The backend contract

The backend is any object responding to:

search(query, **opts) -> Enumerable of {title:, url:, snippet:}

Rows may be Hashes or any object responding to #to_h. Nexo normalizes each row to {title:, url:, snippet:} (all stringified), truncates the snippet to 300 chars, and returns at most 8 rows:

{ results: [{ title: "…", url: "https://…", snippet: "… (≤300 chars)" }, ] }   # ≤8 rows

On any denial or error the tool returns { error: … } (recoverable) and never raises into the loop. The v1 tool exposes only query; result count, region, safesearch and other **opts stay a host-side backend concern and are never populated by the tool.

Security — read before injecting a backend

Live example

A live example demonstrates a host-injected search_backend plus fetch:

NEXO_LIVE=1 NEXO_MODEL=… ruby -Ilib examples/news_search.rb

View examples/news_search.rb on GitHub →


Next steps

Tools

The four sandbox-backed file and shell tools.

MCP

Attach external MCP servers for more capabilities.

--- URL: https://maquina.app/documentation/nexo/tools/ Title: Tools ---

Nexo attaches four sandbox-backed tools — ReadFile, WriteFile, Shell, and Glob — each gated by the sandbox and permission seams. Which tools attach depends on what the sandbox supports; what they may do depends on the permission mode.


The four sandbox tools

Tool Capability What it does Attached when
ReadFile :read Read a file from the sandbox Always
WriteFile :write Write a file into the sandbox Always (gated by the permission mode)
Glob :glob Match files by pattern Always
Shell :shell Run a shell command in the sandbox Only when sandbox.supports?(:shell)

ReadFile/WriteFile/Glob are always attached. Shell attaches only when the sandbox supports it — so a :virtual agent never advertises a Shell tool it can never run. Local/Container support all four capabilities; Virtual supports everything but :shell (it raises NotImplementedError on purpose — in-memory means no command execution).

Safe by default: under :read_only, :read/:glob are auto-allowed and :write/:shell are denied — the agent can look but not touch. Grant individual capabilities with Permissions.new(mode: :read_only, allow: %i[read glob fetch]) without changing the mode.


Shell — output truncation

Unbounded command output (npm install, git log) is truncated before it reaches the model, so a single command can’t blow a small context window. Tools::Shell wraps stdout/stderr through Nexo::OutputTruncator.call(text, max_lines: 200, max_chars: 16_000) — strips ANSI escapes, keeps the last max_lines lines, appends a …[truncated N lines] marker, then caps at max_chars. The integer status passes through untouched.

Pure line/char truncation — no tokenizer; configurable via the kwargs only (no global config, no per-agent macro).


WriteFile — read-before-write + stale guard

Within a session, the agent is blocked from overwriting a file it never read, or one that changed underneath it. Agent#chat builds one Nexo::ReadTracker per chat and threads it into ReadFile (records (path, mtime) on a successful read) and WriteFile (enforces):

The guard is real-FS only — skipped entirely on Virtual (nil mtime) and when no tracker is passed (direct tool construction). Best-effort: mtime-based, so a sub-second external edit may slip past the stale check (read-before-write is the primary guard). Clobber-safety within a session only — no versioning, locking, or VCS semantics.


Failure model — errors, not exceptions

A denied capability returns { error: ... } to the model (recoverable) and never raises into the loop — identical to a sandbox tool failure. A path that escapes the workspace raises SecurityError (sandbox misuse); everything else surfaces as recoverable context for the model to adapt to.


The fetch tool for reading the web and the search tool for discovering URLs live in the Web guide. They are gated by their own :fetch and :search capabilities (denied under :read_only exactly like :shell) plus a host allow-list / an injected backend, and they run in the host process — no sandbox constrains them, not even a --network none container.

These are sandbox refinements as much as tool behavior — see Sandboxes for the guard details behind each capability, and MCP for attaching external tool servers through the protocol.


Next steps

MCP

Attach external MCP servers behind a fail-closed allow-list.

Web

The fetch and search tools for reading and discovering the web.

--- URL: https://maquina.app/documentation/nexo/skills/ Title: Skills ---

A skill is a SKILL.md package — frontmatter plus instructions — that teaches the model how you want a task done. Skills guide reasoning; the sandbox-backed tools perform execution. Nexo does not implement skill loading; it composes the ruby_llm-skills gem so you attach a skill with one macro and no loader setup.


A skill package

Drop a package under app/skills/ (or scaffold one — see below):

app/skills/
└── triage/
    ├── SKILL.md          # frontmatter (name, description) + process steps
    └── references/       # supporting docs the skill can cite
---
name: triage
description: Triage incoming issues by severity and route them to the right owner.
---

# Triage

## Process
1. Classify the issue severity.
2. Route to the right owner.

Reference it with the skills macro — its instructions are layered on top of the agent’s own, in declaration order:

require "nexo"

class TriageAgent < Nexo::Agent
  model ENV.fetch("NEXO_MODEL")   # any ruby_llm model — never a hardcoded vendor default
  skills :triage                  # one macro, no loader wiring
end

TriageAgent.new.chat   # chat built with the base sandbox tools + the skill's instructions

Scaffold a skill

Scaffold a new skill package with the generator (creates a valid SKILL.md plus a references/ directory):

rails g nexo:skill triage
#   create  app/skills/triage/references/.keep
#   create  app/skills/triage/SKILL.md

An optional dependency

ruby_llm-skills is an optional dependency — required lazily only when you use a skill. Without it installed, require "nexo" still loads; touching a skill raises a clear Nexo::MissingDependencyError telling you to add gem "ruby_llm-skills". Referencing a skill that does not exist raises Nexo::Error naming the missing SKILL.md path.


Skill tools stay gated

A skill contributes instructions only. A loaded skill ships no independent tools, and Nexo deliberately does not attach ruby_llm-skills’ progressive-disclosure tool (which reads files outside the sandbox). The model reaches a skill’s references//scripts/ files through Nexo’s own permission-gated, sandbox-backed tools — so attaching a skill never widens what an agent can do beyond its configured sandbox/permission mode.

Safe by default: skills add reasoning, never authority. A :read_only agent with a skill is still :read_only.


Live example

A runnable example points a code-reviewer agent at a local Ollama model, attaches a ruby-code-review skill, and accounts tokens per prompt:

NEXO_MODEL=gemma3:12b ruby -Ilib examples/code_reviewer.rb

The skill package it uses lives at examples/skills/ruby-code-review/SKILL.md in the repo.

View examples/code_reviewer.rb on GitHub →


Next steps

Loops

The engine that runs the skilled agent's turns.

Tools

The sandbox-backed tools a skill teaches the model to use.

--- URL: https://maquina.app/documentation/nexo/sessions/ Title: Sessions ---

A Workflow is fire-and-finish. A Nexo::Session is the other half: a remembering instance of an agent, addressed by (agent_name, instance_id), that accumulates context across separate invocations.

Nexo::Session.resume(Assistant, "user-42").prompt("My name is Mac.")
# ... a later request, job, or process ...
Nexo::Session.resume(Assistant, "user-42").prompt("What is my name?")
# => "...Mac..." — the persisted thread carried the earlier turn

resume finds-or-creates the one thread for that pair (the pair is unique — one thread per pair) and returns a session whose #prompt appends to it. #prompt takes the same max_turns: and &on_event block as Agent#prompt, yielding the same (:tool_call | :tool_result | :done, payload) events. Extra keywords are forwarded to the agent constructor (e.g. Nexo::Session.resume(Assistant, "u1", cwd: repo)).

A session adds only memory + addressability — never authority. Its sandbox, permissions (default :read_only), skills, MCP servers, and fetch_allow are exactly the agent’s; opening or resuming a session never widens what the agent can do. The persisted record supplies the thread; the agent supplies the tools/skills/instructions onto it.


Composition — acts_as_chat, owned by the host

Message persistence is RubyLLM’s acts_as_chat — Nexo defines no message table and serializes nothing. The host Rails app owns all four persistence models (Chat, Message, ToolCall, Model), generated by ruby_llm’s own installer:

rails g ruby_llm:install      # generates the Chat/Message/ToolCall/Model models + migrations

One setup step beyond the installer: the session chat model must be addressable, so add two columns and a unique composite index to the generated chats table:

class AddNexoAddressingToChats < ActiveRecord::Migration[8.0]
  def change
    add_column :chats, :agent_name,  :string
    add_column :chats, :instance_id, :string
    add_index  :chats, [:agent_name, :instance_id], unique: true
  end
end

Tell Nexo which model hosts sessions (only if it isn’t ruby_llm’s default Chat):

Nexo.configure { |c| c.session_chat_model = "Chat" } # default; a String class name,
                                                     # constantized lazily at resume time

Rails-only durability — plain Ruby is in-memory

Durable sessions require ActiveRecord. Backend selection guards on defined?(::ActiveRecord::Base) and the host chat model being defined (mirroring how RunStore only uses the AR store when Nexo::WorkflowRun is present):

Re-applying the agent’s instructions on every resume is idempotent: acts_as_chat stores instructions as role: :system messages, and Nexo re-applies them with with_instructions (replace semantics) so the stored thread keeps exactly one copy across resumes rather than accumulating duplicate system messages. The runtime tools (the four sandbox tools + MCP + fetch) are re-attached each resume — they are not persisted, and that is correct.


Retention, PII, and #close — the honest trade-off

A continuing session is a persistence surface, and that has real costs:


Live example

A runnable, env-gated two-prompt resume is in the repo:

NEXO_LIVE=1 NEXO_MODEL=… ruby -Ilib examples/chat_session.rb

View examples/chat_session.rb on GitHub →


Next steps

Loops

The engine that drives an agent's tool-calling turns.

Concurrency

Fan out sessions and agents without tripping rate limits.

--- URL: https://maquina.app/documentation/nexo/sandboxes/ Title: Sandboxes ---

The sandbox is where an agent’s tools act. Pick in-memory Virtual, host-backed Local, a throwaway Container, or a Remote you inject. Two seams compose the execution environment: the sandbox is where tools act; the permission mode is what they may do (Permissions).

A denied capability returns { error: ... } and the agent loop continues — it does not raise. A path that escapes the workspace raises SecurityError; an agent built with no resolvable model raises Nexo::ConfigurationError.


The four sandboxes

Sandbox What it is :shell Best for
Virtual (default) In-memory, zero host access raises NotImplementedError (intentional) Reading staged data, pure-Ruby work
Local Host filesystem + shell, guarded to cwd Yes (narrowed ENV) Trusted dev/CI
Container Throwaway OCI container via docker or Apple container CLI Yes (in container) Model-driven work, untrusted models
Remote A remote container you inject (E2B / Daytona / Modal / Docker / your own) Yes (via injected client) Cloud sandboxes, scale-out

Safe by default: agents start :virtual — escalating to :local, :container, or :remote is always an explicit choice in your code. The default never widens host access.


Capability matrix

Tools::Fetch and Tools::WebSearch (:fetch / :search) run in the host process (stdlib net/http / a host-injected backend), so no sandbox constrains them — not even a --network none container. They are bounded only by the capability gate plus fetch_allow / the injected backend. See Web.

  :read :glob :write :shell :fetch :search
Virtual sandbox Yes Yes Yes (in-memory) No NotImplementedError{error} Yes † Yes †
Local sandbox Yes (guarded) Yes Yes (guarded) Yes (narrowed ENV) Yes † Yes †
Container sandbox Yes (guarded) Yes Yes (guarded, scratch) Yes (in container) Yes † Yes †

:fetch / :search run in the host process — no sandbox constrains them.

A :virtual agent never advertises a Shell tool it can never run — Agent#chat attaches Shell only when @sandbox.supports?(:shell). ReadFile/WriteFile/Glob are always attached. See Tools.


Safety refinements — safer, more legible real-FS sandboxes

Five small refinements tighten the real-filesystem sandboxes (Local, Container) and make the execution environment more legible to the model. Each wires into an existing seam — no new sandbox tier, no new capability, no new dependency. Every one tightens a default or narrows scope; none widens authority silently.


Remote sandbox — bring your own container

Sandboxes::Remote contains zero vendor code. It wraps any object that satisfies a four-method contract — read, write, exec, close — and delegates the Sandbox interface to it. Switching providers is swapping the injected object, nothing else:

sandbox = Nexo::Sandboxes::Remote.new(client: my_container_client)
# read(path)            -> client.read(path)
# write(path, content)  -> client.write(path, content)
# shell(cmd, timeout:)  -> client.exec(cmd, timeout:)
# glob(pattern)         -> client.exec(<pattern as a positional $1, never interpolated>)
# close                 -> client.close

Vendor SDKs rarely expose exactly read/write/exec/close, so adapt them with a tiny shim object. Keep the vendor gem a soft dependency behind a lazy require that raises Nexo::MissingDependencyError when it’s absent:

# A ~10-line adapter wrapping a hypothetical vendor client to the four-method contract.
class E2BAdapter
  def initialize(api_key:)
    require "e2b"            # soft dep — lazy, only when you actually use it
    @sbx = E2B::Sandbox.create(api_key: api_key)
  rescue LoadError
    raise Nexo::MissingDependencyError, "E2BAdapter needs `gem \"e2b\"` in your Gemfile."
  end

  def read(path)              = @sbx.files.read(path)
  def write(path, content)    = @sbx.files.write(path, content)
  def exec(cmd, timeout: 30)  = (r = @sbx.commands.run(cmd, timeout: timeout)
                                 {stdout: r.stdout, stderr: r.stderr, status: r.exit_code})
  def close                   = @sbx.kill
end

agent = Nexo::Agent.new(model: ENV.fetch("NEXO_MODEL"),
                        sandbox: Nexo::Sandboxes::Remote.new(client: E2BAdapter.new(api_key: ENV["E2B_API_KEY"])))

Nexo ships only Remote plus this documented pattern — purpose-built Sandboxes::E2B / Sandboxes::Daytona classes are a possible future addition, deliberately left out of v1 because their vendor client APIs aren’t pinned yet.


Container sandbox — Docker / Apple Container

Sandboxes::Container runs an agent’s tools inside a throwaway OCI container via the docker (default) or Apple container CLI — shell-out only through Open3, no client gem, no Compose, no image builder. A model-driven agent never touches your host filesystem or shell directly. Declare it with the sandbox macro (image: is required — there is no default image):

class ContainerReviewer < Nexo::Agent
  model   ENV.fetch("NEXO_MODEL")
  sandbox :docker, image: "node:22-slim",
          binds: { Dir.pwd => { to: "/workspace/repo", mode: :ro } }
end

The container cwd defaults to /workspace (a container path, not your host directory); the host dir enters only through a binds: entry.

runtime: — one class, two CLIs

sandbox :docker (or runtime: :docker) shells out to docker; sandbox :apple (runtime: :apple) shells out to Apple’s container binary. The run/exec surface is largely shared; where the CLIs diverge (networking especially) the class branches on the runtime. Apple container parity is NOT yet verified — the flags are encoded from the reference mapping, not confirmed against a live daemon, so every Apple flag must be verified before trust. An unknown runtime raises Nexo::ConfigurationError.

Hardened by default — every knob an explicit opt-out

All of the following are applied to the run argv by default and individually invertible:

Concern Default Loosen with
Network --network none (no egress) network: :bridge / :host / a network name
Capabilities --cap-drop ALL cap_add: %w[NET_BIND_SERVICE ...]
Rootfs --read-only readonly_rootfs: false
Writable scratch --tmpfs <cwd>:rw (ephemeral), only when readonly_rootfs a :rw host bind for persistence
Privilege escalation --security-opt no-new-privileges (not exposed)
PIDs --pids-limit 512 (fork-bomb guard) pids_limit: (nil omits the flag)
Memory / CPU unset (host decides) memory: / cpus:
User / uid left to the image user: (opt-in defense-in-depth)
Host binds read-only (:ro) per-bind { to:, mode: :rw }
Env vars none env: { "KEY" => "val" } → one -e KEY=val per entry

Bind spec forms:

binds: { "/host/proj" => "/workspace/proj" }                       # -> :ro
binds: { "/host/proj" => { to: "/workspace/proj", mode: :rw } }    # -> :rw

Non-root is not forced. The image’s own uid is respected; user: is an opt-in. The other hardening applies regardless of uid.

Every argument is passed to Open3 as an array, never string-interpolated, so file contents and commands can’t break out of the argv. Paths are expanded against the container cwd; a path that escapes raises SecurityError. A denied/failed tool op surfaces as { error: ... } through the gated tool layer; the sandbox itself raises only on misuse — a missing binary (Nexo::ConfigurationError naming the binary), a path escape (SecurityError), or a container start failure (Nexo::Error).

Lifecycle — ephemeral by default, opt-in reconnect

The container starts lazily on first tool use and its id is memoized.

Honest caveats

Live example

A runnable end-to-end container example is in the repo — the agent reads the mounted repo but never touches your host directly, with no network, dropped capabilities, a read-only rootfs, and the host repo bind-mounted read-only. When the run ends, agent.close tears the container down.

NEXO_LIVE=1 NEXO_MODEL=gemma3:12b ruby -Ilib examples/container_review.rb /path/to/repo

View examples/container_review.rb on GitHub →


Next steps

Permissions

The second safety axis — what tools may do, on top of where they run.

Tools

The four sandbox-backed tools gated by these seams.

--- URL: https://maquina.app/documentation/nexo/rails/ Title: Rails ---

Rails wiring: run a Workflow asynchronously on your existing ActiveJob adapter, broadcast its events live, and query runs and artifacts from your own controllers. Nexo ships no queue, no scheduler, no cable backend, and no UI — only the primitives plus one overridable partial.

The install generator (rails g nexo:install) is covered in Getting started; the per-feature generators (rails g nexo:workflows, nexo:artifacts, nexo:state, nexo:skill) live with their topics in Workflows, Durable workflows, and Skills.


Install the store (needed for cross-process run_later)

run_later enqueues a job that carries only the run id; the worker looks the run up in the store. For a worker in another process to find it, use the ActiveRecord store:

rails g nexo:install     # config/initializers/nexo.rb
rails g nexo:workflows   # the nexo_workflow_runs migration
rails db:migrate

In config/initializers/nexo.rb, opt into the pieces you want:

Nexo.configure do |config|
  config.default_model = ENV["NEXO_MODEL"]
  config.job_queue = :nexo         # route workflow jobs to a dedicated queue (optional)
  config.broadcast_events = true   # opt-in Turbo mirror (requires turbo-rails)
end

Background execution — run_later

MyWorkflow.run_later(payload) enqueues the run on your existing ActiveJob adapter and hands back the run immediately (status "queued"), so a controller can return while the work happens in the background. The job carries only the run id — the payload lives on the run record, so no arguments (and no secrets) travel through the queue. When the worker picks it up, it reconstitutes the workflow and calls the same execute the sync path uses, so an async run reaches the identical done/failed lifecycle, event log, and status notifications:

class GenerateReport < Nexo::Workflow
  def call(payload) = { url: build_report(payload[:account_id]) }
end

run = GenerateReport.run_later(account_id: 42)   # returns at once
run.status                                        # => "queued"
# ...the worker runs it in the background; later:
Nexo::RunStore.default.find(run.id).status        # => "done"

Route jobs to a dedicated queue per call or globally:

GenerateReport.run_later(account_id: 42, queue: :nexo)  # per-call
Nexo.configure { |c| c.job_queue = :nexo }              # or a global default

Scheduling a future run or resume

run_later and resume_later accept wait: (a duration) or wait_until: (an absolute time), forwarded straight to the installed ActiveJob’s own .set(...) scheduler — Nexo adds no scheduler of its own. Use them to defer an initial enqueue (“send this digest at 9am”) or to let a suspended run wake itself on a timer, symmetrically:

# Defer the initial enqueue until tomorrow morning.
DailyDigest.run_later({account_id: 42}, wait_until: Date.tomorrow.noon)

# Let a suspended run wake itself up in an hour (no separate scheduled job).
MyWorkflow.resume_later(run.id, {reminder: true}, wait: 1.hour)

The run’s status is unchanged — a scheduled run_later is still "queued" (no "scheduled" status is invented), and a scheduled resume_later leaves the run "suspended" until the job fires. Passing both wait: and wait_until: in one call raises ArgumentError (checked before any run is created or job enqueued). With neither given, the enqueue is byte-for-byte the immediate one above.

wait:/wait_until:/queue: are scheduling options, not payload. A bare-keyword call consumes them as options: run_later(wait: 60) schedules the job 60 seconds out and leaves the payload {} — it does not store "wait" => 60 as data. A payload that legitimately needs a key named "wait" must be passed as an explicit positional Hash: run_later({wait: "value"}).

This is still “no scheduler, no cron” — wait:/wait_until: schedule a single future run/resume via ActiveJob; recurring schedules stay the host’s.

No queue, no scheduler — and the honest caveats

Nexo ships no queue and no scheduler — ActiveJob uses whatever adapter your app configured (Sidekiq, GoodJob, Solid Queue, …), and scheduling (cron / GoodJob / whenever) stays the host’s. Without ActiveJob, run_later raises Nexo::MissingDependencyError — use run for synchronous execution.

Needs a shared store. For a worker in another process to find the run, use the ActiveRecord store with a real adapter — the run must live in the database, not in a per-process memory store. The in-memory store only works under the :inline/:test adapters, where the job runs in-process on enqueue.

No automatic crash recovery / no automatic retries. A crashed or retried job re-runs #call from scratch — Nexo adds no retry_on (configure retries in your host job if you want them). Pair with reconcile_interrupted! (Workflows) to sweep runs orphaned in "running". For an intentional pause-and-continue, see Durable workflowscheckpoint skips already-paid-for work when a run resumes.


Live progress — notifications and opt-in Turbo

Every run broadcasts as it happens over ActiveSupport::Notifications, decoupled from persistence (events still buffer/persist separately). Two notifications fire (a no-op with no ActiveSupport, so the plain-Ruby core stays Rails-free):

The payloads carry only what emit/the run already hold — no payload or credential dumps. Subscribe for logging, metrics, or your own UI:

ActiveSupport::Notifications.subscribe("nexo.workflow.event") do |*, payload|
  Rails.logger.info("[run #{payload[:run_id]}] #{payload[:event]["type"]}")
end

Opt-in Turbo mirror

Set config.broadcast_events = true (and have turbo-rails present) and the engine subscribes Nexo::TurboBroadcaster, which appends each event to a per-run Turbo stream, rendering the overridable partial app/views/nexo/_event.html.erb. To show live progress, add to your own page (Nexo ships no controllers, routes, or dashboard — the host owns all HTTP + UI):

<%= turbo_stream_from "nexo_run_#{@run.id}" %>
<div id="nexo_run_<%= @run.id %>_events">
  <%# appended events land here %>
</div>

Override the appearance by defining your own app/views/nexo/_event.html.erb in the host app — it takes precedence over the engine’s default.

Nexo.configure { |c| c.broadcast_events = true }   # opt in; requires turbo-rails

Broadcast reachability. Broadcasts fire from wherever the run executes — under run_later, that’s the worker process. The cable backend (AnyCable, Solid Cable, Redis) must therefore be reachable from your workers, not just your web dynos. Nexo ships no cable backend — broadcasting composes whatever the host configured. Without turbo-rails, broadcast_events is a harmless no-op: the notifications still fire, so you can subscribe to them yourself.


Run helpers for a host UI

Nexo::WorkflowRun exposes query helpers so a host can build its own runs UI without Nexo dictating controllers or views:

Nexo::WorkflowRun::STATUSES  # => %w[pending queued running done failed interrupted suspended]

Nexo::WorkflowRun.queued     # scope: status "queued"
Nexo::WorkflowRun.running    # scope: status "running"
Nexo::WorkflowRun.finished   # scope: status "done" or "failed"
Nexo::WorkflowRun.suspended  # scope: status "suspended" (paused, awaiting resume)

run.queued?  run.running?  run.done?  run.failed?  run.suspended?   # predicates

# Artifact access — content only; serving files stays your
# controller's job (Nexo ships no artifact routes/controllers):
run.artifact("digest.md")          # => {"name" =>, "content" =>, "at" =>} or nil
run.artifact_content("digest.md")  # => "…the body…" or nil

Artifact access is content only; serving files stays your controller’s job — Nexo ships no artifact routes or controllers.


Walkthrough

A controller + Turbo-page host-side walkthrough is in the repo — install the store, define a workflow, enqueue it from a controller, and render live progress:

View examples/rails_usage.md on GitHub →

A live example also wraps an MCP-backed agent in a workflow and captures the digest as an artifact (Task + run_agent):

View examples/inbox_digest_task.rb on GitHub →


Next steps

Workflows

The run primitive run_later executes in the background.

Durable workflows

Pause and continue a run across processes.

--- URL: https://maquina.app/documentation/nexo/permissions/ Title: Permissions ---

The permission mode is what an agent’s tools may do. Two seams compose the execution environment: the sandbox is where tools act (Sandboxes); the permission mode is what they may do.

Safe by default: agents start :read_only — read and glob are auto-allowed; every write, shell, fetch, and search is denied until you explicitly opt in. A denied capability returns { error: ... } and the agent loop continues — it does not raise. A path that escapes the workspace raises SecurityError; an agent built with no resolvable model raises Nexo::ConfigurationError.


The four modes

Mode :read/:glob :write/:shell/:fetch/:search When to use
:read_only (default) Yes No {error} Untrusted models, the safe baseline
:auto Yes Yes Fully trusted local dev/CI
:ask Yes per on_ask A human at the keyboard during a synchronous run
:approve Yes per decision Durable, cross-process human-in-the-loop (see Durable workflows)

:read/:glob are auto-allowed under every mode (they sit in the default allow list), so :ask/:approve never prompt for them — only :write/:shell/:fetch/:search reach the gate.

You can also grant individual capabilities without changing the mode — Permissions.new(mode: :read_only, allow: %i[read glob fetch]) lets fetch through while keeping write/shell denied. This is how the Web tools are typically enabled.


The gate

A denied capability returns { error: ... } to the model (recoverable) and never raises into the loop — identical to a sandbox tool failure. The gate is a first-class capability check, not a coarse mode switch, so a :read_only agent that never calls fetch_allow gets no fetch tool at all (see Web).

Escalation is always explicit in your code: :auto, an allow: list, a populated mcp_allow, or :ask with a real on_ask.


The MCP gate — a second, fail-closed axis

MCP tools obey a second permission axis, separate from the sandbox capability axis, because an MCP tool executes inside the server, outside the sandbox. mcp_allow is the exact-match allow-list:

Mode MCP tool behavior
:read_only (default) allow only tool names listed in mcp_allow; everything else denied
:ask call on_ask.call(:mcp, {tool:, args:}); truthy allows, else deny
:approve names in mcp_allow are pre-approved; any other tool needs a human decision — undecided suspends the run, approved: true allows, approved: false denies
:auto allow every MCP tool

mcp_allow defaults to [], so attaching a powerful server under :read_only with no allow-list denies every tool — a misconfigured agent fails closed, not open. Matching is exact tool-name only — no globs or regexes. See MCP for the full detail.


Human-gated writes (:ask)

:ask mode defers every write/shell action to your callback. Build a Permissions with an on_ask hook and pass it in:

gate = Nexo::Permissions.new(mode: :ask, on_ask: ->(cap, detail) {
  $stdout.print("Allow #{cap} #{detail}? [y/N] "); $stdin.gets.strip == "y"
})

class Editor < Nexo::Agent
  model   ENV.fetch("NEXO_MODEL")
  sandbox :local
end

Editor.new(cwd: ".", permissions: gate).prompt("Fix the typo in README.md")

The bare :ask symbol resolves to Permissions.new(mode: :ask) with no callback, so writes/shell are denied — pass a pre-built Permissions with on_ask for a real gate.

Scope which actions prompt — ask_when

Under :ask, ask_when scopes which actions actually prompt a human. When the predicate returns falsey the action is auto-allowed without calling on_ask; truthy (or when ask_when is unset) falls through to on_ask exactly as before. Unset = ask for everything. It only ever narrows what is auto-allowed — it never widens authority.

# Only prompt for writes under /protected; auto-allow everything else.
perms = Nexo::Permissions.new(
  mode: :ask,
  on_ask:   ->(cap, detail) { ask_the_human(cap, detail) },
  ask_when: ->(cap, detail) { cap == :write && detail.to_s.start_with?("/protected") }
)

Durable approval (:approve)

:approve is the durable, cross-process sibling of :ask for the case where a run_agent-driven agent hits a permission gate mid-loop and you want that to pause the run for a human, not run unchecked and not block a worker.

The loop is: :approve gate with no decision → Nexo::ApprovalRequiredrun_agent suspends → host renders the pending call → resume(approved:) threads the decision back through the gate.

Nexo::Permissions.new(mode: :approve,
  approve_when: ->(cap, detail) { cap == :write && detail.to_s.start_with?("/protected") })

:ask (in-process on_ask) is the right choice with a human at the keyboard during a synchronous run; :approve is its durable, cross-process sibling for run_later/resume_later. See Durable workflows for the suspend/resume mechanics and the honest caveats (re-entry not replay, one approval per suspend cycle, needs the ActiveRecord store + ActiveJob for cross-process).

Live example

The :approve mode bridged to a durable suspend/resume is exercised by a live example in the repo:

NEXO_LIVE=1 NEXO_MODEL=… ruby -Ilib examples/approval_agent.rb

View examples/approval_agent.rb on GitHub →


Next steps

Sandboxes

The other safety axis — where an agent's tools act.

Durable workflows

Take the :approve gate cross-process with suspend and resume.

--- URL: https://maquina.app/documentation/nexo/mcp/ Title: MCP ---

An MCP server exposes tools to a model over the Model Context Protocol — Gmail, a filesystem, a fetch endpoint, Drive, and so on. Nexo does not implement MCP; it composes the ruby_llm-mcp gem so you attach one or more servers with a single mcp macro and no client wiring. Because a server is reached through the protocol (never a vendor SDK), the behavior is identical on Anthropic, a local model, or anything else ruby_llm supports.


Attach servers with one macro

require "nexo"

class InboxDigest < Nexo::Agent
  model       ENV.fetch("NEXO_MODEL")   # any ruby_llm model — never a hardcoded vendor default
  permissions :read_only
  mcp :gmail, transport: :stdio, command: "npx", args: %w[-y @modelcontextprotocol/server-gmail]
  mcp :fs,    transport: :stdio, command: "npx", args: %w[-y @modelcontextprotocol/server-filesystem /data]
  mcp :fetch, transport: :sse,   url: "http://localhost:8080/sse"
  mcp_allow %w[search_threads get_thread]
end

Each mcp line accumulates a server declaration. name and transport map onto the client’s name:/transport_type:; every other keyword is passed through verbatim as the server’s config:command:/args: for :stdio, url: for :sse. The server’s tools are attached to the chat after the sandbox tools and skills, and fire the same before_tool_call/after_tool_result observability callbacks, so MCP calls appear in a run’s event log automatically.


Every MCP tool call is gated — and fails closed

MCP tools obey a second permission axis, separate from the sandbox capability axis, because an MCP tool executes inside the server, outside the sandbox. mcp_allow is the exact-match allow-list threaded into the agent’s permissions:

Mode MCP tool behavior
:read_only (default) allow only tool names listed in mcp_allow; everything else is denied
:ask call on_ask.call(:mcp, {tool:, args:}); a truthy return allows, else deny
:approve names in mcp_allow are pre-approved; any other tool needs a human decision — undecided suspends the run (Nexo::ApprovalRequired), approved: true allows, approved: false denies (the durable sibling of :ask)
:auto allow every MCP tool

mcp_allow defaults to [], so attaching a powerful server under :read_only with no allow-list denies every tool — a misconfigured agent fails closed, not open. A denied call returns { error: … } to the model (recoverable) and never raises into the loop — identical to the sandbox tools. Escalation (:auto, a populated mcp_allow, or :ask with a real on_ask) is always explicit in your code. Matching is exact tool-name only — no globs or regexes.

Safe by default: attaching an MCP server adds no permission surface. The unchanged gate denies everything under :read_only until the exact name is in mcp_allow (default [] ⇒ deny-all).

Two caveats — read before attaching a server

  1. MCP tool effects are not sandboxed. The gate covers the authority to invoke a tool; the tool then runs in the MCP server, outside Nexo’s sandbox. Nexo cannot constrain what that server does with a call it is authorized to make — attaching a write server and allowing a write tool means real writes happen. Gate deliberately, and prefer :read_only with a tight mcp_allow.
  2. Connection lifecycle. Clients are built once and memoized on the agent instance, reused across prompts. A long-lived agent holding stdio/SSE servers should call Agent#close when done to tear the connections down:

    agent = InboxDigest.new
    agent.prompt("Summarize invoices from this week")
    agent.prompt("Any follow-ups needed?")   # reuses the same live MCP connections
    agent.close                              # stops every attached server
    

HTTP-family servers + an OAuth token: provider

Beyond :stdio, Nexo attaches a server over any HTTP-family transport ruby_llm-mcp supports — transport: :http, :sse, or :streamable. For an OAuth-authenticated hosted server (Gmail, Drive, …) add a token: — a static bearer String, or a callable re-read close to connection time. Nexo resolves it and injects an Authorization: Bearer <token> header per connection:

class InboxTriageHTTP < Nexo::Agent
  model       ENV.fetch("NEXO_MODEL")
  permissions :read_only

  # Hosted Gmail MCP server over HTTP; the host supplies the OAuth access token.
  mcp :gmail,
    transport: :http,
    url:       ENV.fetch("GMAIL_MCP_URL"),
    token:     -> { Current.user.gmail_access_token }   # re-read at connection time

  # READ tools only — the unchanged gate denies send/trash/modify.
  mcp_allow %w[search_threads get_thread list_messages get_message list_labels]
end

A static token (token: ENV.fetch("GMAIL_TOKEN")) is equally valid. Under the hood Nexo strips token: and hands off:

RubyLLM::MCP.client(
  name: "gmail", transport_type: :http,
  config: { url: "https://…", headers: { "Authorization" => "Bearer <resolved>" } }
)

Any other headers: you pass are preserved; Nexo’s Authorization wins. With no token:, config: passes through byte-for-byte (no headers key) — the :stdio path is untouched.

Nexo does not own the OAuth flow. It performs no authorization-code exchange, no token refresh, and keeps no token store — that is your app or an OAuth library. Nexo’s only job is to call the provider, inject the header, and hand off. The token is never logged, persisted, placed in a URL/query string, or emitted in an event.

Refresh / reconnect caveat

ruby_llm-mcp’s HTTP-family transports snapshot the headers hash at construction — there is no per-request header callback for a plain headers Hash. A callable token: is therefore resolved once, when the client is built, and the client is memoized on the agent instance across prompts. So when a token rotates, tear the connection down and reconnect to pick up the new value:

agent.close                  # stops the memoized MCP client
agent.prompt("…")            # a fresh prompt rebuilds the client → token: re-resolved

The gate is unchanged — an HTTP OAuth server’s tools are gated exactly like :stdio tools. Attaching an authenticated server adds no permission surface.

Two honest caveats — read before attaching a token

  1. Refresh may require a reconnect. Because headers are construction-only, a rotated token needs agent.close + a fresh prompt, not just a new proc return. A static token stays constant for the client’s life.
  2. The token is a live credential. Even gated, an authorized MCP call runs its effect server-side — a leaked bearer is a real compromise. Nexo keeps it out of logs, events, persisted WorkflowRun records, and URLs; your host code must do the same. Nexo does not police ruby_llm-mcp’s own internal logging of headers — that boundary is yours.

An optional dependency

ruby_llm-mcp is an optional dependency — required lazily only when you attach a server. Without it installed, require "nexo" still loads; building a server raises a clear Nexo::MissingDependencyError telling you to add gem "ruby_llm-mcp".


Live example — start here for MCP

A no-auth-required example uses the official filesystem MCP server (npx, no credentials) so you can watch the safe-by-default gate in action end to end: read tools on the mcp_allow list are allowed; write_file is not on the list and the gate returns { error: ... } (denied).

NEXO_LIVE=1 NEXO_MODEL=gemma3:12b ruby -Ilib examples/mcp_filesystem.rb /tmp

View examples/mcp_filesystem.rb on GitHub →


Next steps

Web

Host-process fetch and search, gated by a capability and allow-list.

Permissions

How the mcp_allow axis composes with tool capabilities.

--- URL: https://maquina.app/documentation/nexo/loops/ Title: Loops ---

The loop is the engine that drives one prompt to completion. Swapping it is constructor injection (loop:) — the agent class never changes. Two backends ship.


The two backends

  Loops::RubyLLM (default) Loops::AgentSDK (opt-in)
Provider neutral Yes — any ruby_llm model No — Anthropic-oriented
Tool source your sandbox-backed tools the SDK’s own built-in/host tools
Turn cap observability only (see caveat) native max_turns hard cap
Execution location your sandbox (virtual/local/remote) the host process

The whole point: same agent code, swapped backends. Both examples are model-agnostic (ENV.fetch("NEXO_MODEL") — never a hardcoded "claude-…"):

# Claude fast path — AgentSDK's own loop + host tools + native max_turns
claude = Nexo::Agent.new(
  model: ENV.fetch("NEXO_MODEL"),
  sandbox: Nexo::Sandboxes::Local.new(cwd: "/srv/checkout"),
  permissions: Nexo::Permissions.new(mode: :auto),
  loop: Nexo::Loops::AgentSDK.new
)

# Any-provider path — your sandbox, your tools, human-in-the-loop
gpt = Nexo::Agent.new(
  model: ENV.fetch("NEXO_MODEL"),                # gpt-5.5, gemini, gemma3:12b via Ollama…
  sandbox: Nexo::Sandboxes::Remote.new(client: my_container_client),
  permissions: Nexo::Permissions.new(mode: :ask, on_ask: ->(cap, detail) {
    SlackApproval.request!(capability: cap, detail: detail)
  }),
  loop: Nexo::Loops::RubyLLM.new
)

Loops::AgentSDK wraps RubyLLM::AgentSDK.query and requires the optional ruby_llm-agent_sdk gem (lazy require; a clear Nexo::MissingDependencyError if it’s absent). It maps Nexo’s permission modes onto the SDK’s own vocabulary:

Nexo mode AgentSDK permission_mode
:read_only :default
:auto :bypass_permissions
:ask :default (human gating stays in Nexo’s own on_ask path, not delegated to the SDK)
:approve :default (durable approval stays in Nexo’s own gate; any unmapped mode also falls back to :default)

The turn-cap caveat — read before running untrusted/expensive workloads

ruby_llm runs the whole tool loop inside ask, so Loops::RubyLLM has no clean public hard “stop after N turns” haltbefore_tool_call gives turn-count observability, not a hard stop. (Confirmed: ruby_llm 1.16.0’s Chat exposes no public max-turns/max-iterations setting.) Your three real options:

Do not ship Loops::RubyLLM for untrusted workloads claiming a hard cap that isn’t proven.


Verified vs assumed

Built against ruby_llm 1.16 and ruby_llm-test 0.2. The tool body method is #execute, tools attach with chat.with_tools(*instances), and instructions set with chat.with_instructions. Open3.capture3 has no timeout: keyword on the target Ruby, so Local#shell bounds the command with Timeout.timeout. These may differ on other ruby_llm versions.

Loops::RubyLLM’s turn-count observability uses RubyLLM::Chat#before_tool_call / #after_tool_result, confirmed present on ruby_llm 1.16.0 and guarded with respond_to? so a version lacking them degrades to no observability rather than crashing.

Loops::AgentSDK targets RubyLLM::AgentSDK.query; ruby_llm-agent_sdk is not a dependency of this release, so that signature is assumed (per the gem’s README) and verified-on-install — confirm it the moment you add the gem.


Live smoke (optional)

The core suite is fully offline and deterministic (models stubbed with ruby_llm-test). A real end-to-end check is opt-in and env-gated — small local models like Gemma have weak tool-calling, so it may be flaky and is never a gating test:

ollama serve &
NEXO_LIVE=1 NEXO_MODEL=gemma3:12b bundle exec rake test TEST=test/live_smoke_test.rb

If Gemma’s tool-calling proves too weak, point NEXO_MODEL at a stronger model — the gem stays provider-neutral; only the smoke target changes.


Next steps

Sessions

Give an agent memory that persists across invocations.

Skills

Teach the model how you want a task done with a SKILL.md package.

--- URL: https://maquina.app/documentation/nexo/ Title: Nexo ---

Agent = Model + Harness. Nexo is the connective tissue linking RubyLLM to tools, sandboxes, skills, and runs.

A model alone forgets everything the moment a response ends. The harness is everything else. Nexo gives the RubyLLM ecosystem one cohesive front door with safe defaults — build a working agent in five lines without wiring anything.


Compose, don’t reimplement

Nexo does not rebuild skill loading, the tool-call loop, MCP, or structured output — those already live in the RubyLLM ecosystem (ruby_llm core, ruby_llm-skills, ruby_llm-mcp, ruby_llm-schema). Nexo composes them behind one front door and adds only the two pieces the ecosystem is missing:


Build an agent in five lines

Subclass Nexo::Agent, declare the pieces with class macros, and call #prompt. No sandbox, permission, or tool object is wired by hand, and nothing is vendor-specific — the agent runs on any ruby_llm-supported model (set NEXO_MODEL, e.g. a local gemma3:12b via Ollama, or a hosted model):

require "nexo"

class CodeReviewer < Nexo::Agent
  model       ENV.fetch("NEXO_MODEL")   # any ruby_llm model — never a hardcoded vendor default
  sandbox     :local
  permissions :read_only

  instructions "You are a careful code reviewer. Read files and report issues. Do not write files."
end

CodeReviewer.new(cwd: "/path/to/repo").prompt("Review the auth module")

Safe by default: agents start :virtual + :read_only — an untrusted model has zero host access until you explicitly opt in.


Installation

Add to your Gemfile:

gem "nexo_ai"

Or install directly:

gem install nexo_ai

In a Rails app, run the install generator to create the conventional layout and an initializer:

rails g nexo:install
      create  app/agents/.keep
      create  app/workflows/.keep
      create  app/skills/.keep
      create  config/initializers/nexo.rb

The guides

Guide What’s inside
Getting started install, configuration, first agent, unregistered/local models
Sandboxes virtual / local / remote / container + hardened defaults
Permissions modes, the gate, the MCP gate, :ask, :approve
Tools ReadFile / WriteFile / Shell / Glob
Loops RubyLLM vs AgentSDK, the turn-cap caveat
Workflows lifecycle, staging, artifacts, run_agent, tasks & actions
Durable workflows checkpoint / suspend / resume
Skills SKILL.md packages, gated tools
MCP mcp macro, fail-closed gate, transports
Web fetch tool + SSRF guard, search tool + injected backend
Sessions continuing, addressable memory
Rails engine, run_later, broadcasting, generators
Concurrency opt-in async, buffered emit, fiber servers
Examples runnable scripts — offline and live

Requirements


Status

Early development. The API is not stable. Nexo ships safe defaults and honest caveats — every escalation is an explicit opt-in, and every reduced guarantee is documented rather than silently dropped.


Next steps

Get started

Install Nexo, configure the harness, and build your first agent.

Examples

Runnable scripts — offline primitives and live agents, MCP, and workflows.

GitHub Repository

Source code, issues, and the full guide set in the repo.

RubyGems

Install the latest version from RubyGems.

--- URL: https://maquina.app/documentation/nexo/getting-started/ Title: Getting started ---

Install Nexo, configure the harness in one place, and build your first agent. Defaults are safe and provider-neutral — there is intentionally no hardcoded model.


Installation

Add to your Gemfile:

gem "nexo_ai"

Or install directly:

gem install nexo_ai

In a Rails app, run the install generator to create the conventional layout and an initializer:

rails g nexo:install
      create  app/agents/.keep
      create  app/workflows/.keep
      create  app/skills/.keep
      create  config/initializers/nexo.rb

require "nexo" works in plain Ruby with no Rails loaded.


Configuration

Configure the harness in one place with Nexo.configure:

Nexo.configure do |config|
  config.default_model       = ENV["NEXO_MODEL"] # provider-neutral: no default
  config.default_sandbox     = :virtual          # :virtual | :local | :docker | :apple | a Hash | a Sandbox
  config.default_permissions = :read_only        # :read_only | :auto | :ask | :approve
  config.skills_path         = "app/skills"
  config.concurrency         = :threaded         # :threaded | :async (opt-in fiber offload)
  config.max_in_flight       = 8                 # Nexo.concurrent fan-out bound
  config.buffer_workflow_events = false          # buffer + flush-once workflow events
end

Nexo.config.default_sandbox      # => :virtual
Nexo.config.default_permissions  # => :read_only
Nexo.config.default_model        # => nil unless set

There is deliberately no hardcoded model — you set NEXO_MODEL (or default_model) to any ruby_llm-supported model id.


Build an agent in five lines

Subclass Nexo::Agent, declare the pieces with class macros, and call #prompt. No sandbox, permission, or tool object is wired by hand, and nothing is vendor-specific — the agent runs on any ruby_llm-supported model (set NEXO_MODEL, e.g. a local gemma3:12b via Ollama, or a hosted model):

require "nexo"

class CodeReviewer < Nexo::Agent
  model       ENV.fetch("NEXO_MODEL")   # any ruby_llm model — never a hardcoded vendor default
  sandbox     :local
  permissions :read_only

  instructions "You are a careful code reviewer. Read files and report issues. Do not write files."
end

CodeReviewer.new(cwd: "/path/to/repo").prompt("Review the auth module")

Defaults are safe: an agent with no sandbox/permissions declared gets the in-memory :virtual sandbox and :read_only permissions, so an untrusted model has zero host access until you explicitly opt in.

Safe by default: agents start :virtual + :read_only — an untrusted model has zero host access until you explicitly opt in.


Unregistered models — local tags, self-hosted, brand-new releases

ruby_llm normally validates a model id against its bundled models.json registry and infers the provider from it. A local Ollama tag (gemma3:12b), a self-hosted build, or a model newer than the installed registry isn’t listed there — so declare the provider explicitly and set assume_model_exists to skip the registry lookup:

class LocalReviewer < Nexo::Agent
  model               "gemma3:12b"
  provider            :ollama         # required once the registry lookup is skipped
  assume_model_exists true            # opt out of models.json validation

  instructions "You are a careful code reviewer."
end

Both are class macros with the same reader/writer convention as model. provider is passed straight through to RubyLLM.chat; assume_model_exists defaults to false (registry validation on). Setting assume_model_exists without a provider raises Nexo::ConfigurationErrorruby_llm can’t infer a provider once the lookup is skipped.


Where to next


Next steps

Sandboxes

Choose where an agent's tools act — Virtual, Local, Container, or Remote.

Permissions

Control what those tools may do, read-only by default.

--- URL: https://maquina.app/documentation/nexo/examples/ Title: Examples ---

Each example in the Nexo repo is a small, runnable script. Two kinds:

Run everything from the repo root with ruby -Ilib examples/<name>.rb.


Offline (start here)

Example Shows
artifact_from_template.rb Staging input files into a run’s sandbox + rendering a named artifact from a trusted ERB template
approval_workflow.rb Durable human-in-the-loop: checkpoint + suspend! + resume

Live — agents

Example Shows Extra requirements
code_reviewer.rb The minimal agent against a local Ollama model, with a skill and token accounting Ollama running locally
chat_session.rb A continuing, addressable Nexo::Session that remembers prior turns
container_review.rb Agent tools running inside a locked-down OCI container docker (or Apple container)
news_summary.rb Read-only web fetch scoped by fetch_allow
news_search.rb Host-injected search_backend + fetch a search backend you inject

Live — MCP

Example Shows Extra requirements
mcp_filesystem.rb The MCP seam + permission gate with the official filesystem server — no credentials needed; start here for MCP npx
inbox_digest.rb Gmail through a stdio MCP server + the email_triage skill, read tools only a Gmail MCP server + OAuth
inbox_digest_http.rb The same digest over a hosted HTTP MCP server with a host-supplied OAuth bearer token a hosted Gmail MCP server
inbox_digest_task.rb The digest as a Workflow Task: agent macro + run_agent + a named artifact same as inbox_digest.rb

Live — workflows

Example Shows Extra requirements
approval_agent.rb The :approve permission mode bridged to a durable suspend/resume

Skills used by the examples

The skills/ directory holds the SKILL.md packages the examples reference — email_triage, news_summary, and ruby-code-review. The examples point Nexo.config.skills_path there; in a Rails host the default is app/skills.

Skill Used by
email_triage inbox_digest.rb, inbox_digest_http.rb, inbox_digest_task.rb
news_summary news_summary.rb, news_search.rb
ruby-code-review code_reviewer.rb

Rails walkthrough

A host-side Rails walkthrough covers run_later, live progress, run helpers, and artifact access:

View examples/rails_usage.md on GitHub →


Next steps

Getting started

Install Nexo and build your first agent in five lines.

GitHub Repository

Source code, issues, and the full guide set in the repo.

--- URL: https://maquina.app/documentation/nexo/durable-workflows/ Title: Durable workflows ---

A long-running or human-in-the-loop workflow can pause durably and continue later — possibly in another process — without re-running completed, already-paid-for work. Three small primitives compose over the existing run persistence (no step-graph engine, no replay log, no scheduler):


The basic loop

class DocumentApproval < Nexo::Workflow
  def call(payload)
    document = checkpoint(:fetch) { fetch_expensive(payload[:id]) } # paid for once

    # `resume_input` is {} on the first pass, so we pause; on resume the host
    # feeds { approved: true }, so we fall through and publish.
    suspend!(reason: "awaiting approval") unless resume_input[:approved]

    checkpoint(:publish) { publish!(document) }
    { done: true }
  end
end

run = DocumentApproval.run(id: 42)   # reaches suspend!, returns
run.status                            # => "suspended"
run.suspend_reason                    # => "awaiting approval"  (AR store)
run.state["fetch"]                    # => the fetched document (checkpoint persisted)

# ...later, once a human approves — possibly in another process:
resumed = DocumentApproval.resume(run.id, approved: true)
resumed.status                        # => "done"  (the :fetch block did NOT re-run)

A host UI lists paused runs with the suspended scope and inspects them with the readers (Nexo ships no controllers/views — the UI is your app’s job):

Nexo::WorkflowRun.suspended            # scope: all paused runs
run.suspended?                          # => true
run.suspend_reason                      # => "awaiting approval"
run.checkpoint_result(:fetch)           # => the stored :fetch value, or nil

For a durable, cross-process resume from a background job, enqueue it — the job carries the run id plus the (json-safe) resume input; the payload still lives on the run:

# The resume input is a positional Hash (queue: is the only keyword), so pass it
# as { approved: true } — bare approved: true would bind as an unknown keyword.
DocumentApproval.resume_later(run.id, { approved: true }, queue: :nexo)

Live example

The full offline approval flow is runnable in the repo:

ruby -Ilib examples/approval_workflow.rb

View examples/approval_workflow.rb on GitHub →


Parallel checkpoints — checkpoint_all

When several checkpoints are independent (no step depends on another’s result), run them concurrently with checkpoint_all(name => callable, …) instead of a sequence of checkpoint calls. It fans the pending steps out through Nexo.concurrent — all in flight at once — and persists each step as it completes (not the batch as a whole), so a resume after a partial failure only re-runs the steps that never landed:

class BuildDashboard < Nexo::Workflow
  def call(payload)
    data = checkpoint_all(
      account: -> { fetch_account(payload[:id]) },   # these two run
      usage:   -> { fetch_usage(payload[:id]) }      # concurrently
    )
    { report: render(data[:account], data[:usage]) }
  end
end

checkpoint_all returns a Hash keyed by the original names you passed (data[:account]), with values read back from state — the same shape whether a value came from this pass or a prior one. Each newly-completed step also surfaces a "checkpoint"-typed event on the run’s event log (data is the step name only, never the value — so a dashboard can show batch progress without the event log carrying large or sensitive results). Steps already present in state are skipped silently and emit nothing.

Bound the batch by how many keys you pass — there is no separate rate knob; every pending step goes in flight. Because it drives Nexo.concurrent, checkpoint_all needs the async gem only when something is actually pending — an all-persisted pass returns the prior values directly without touching concurrency. The same restrictions as checkpoint apply: values must be json-serializable, a step must not be named after a reserved state key (__suspend__/__approval__/__buffer_events__ — raises Nexo::Error before any step runs), and do not call suspend! inside a step (undefined — unsupported).

Known trade-off: per-step persistence, not an atomic batch. checkpoint_all is not transactional. If step B raises after step A persisted, A stays in state, B is absent, the run goes "failed", and the exception propagates through the workflow’s normal failure path (Nexo.concurrent’s “first failure re-raises, the rest stop” — it is not rescued away). A subsequent execute of the same run re-submits only the still-missing names — A is skipped, B re-runs. Do not treat a batch as all-or-nothing.


Durable agent approval — :approve

The example above suspends at an explicit suspend! the workflow author placed. The :approve mode adds the durable, cross-process sibling of :ask for the case where a run_agent-driven agent hits a permission gate mid-loop and you want that to pause the run for a human, not run unchecked and not block a worker. Declare the agent under the :approve mode:

class Scribe < Nexo::Agent
  model   ENV.fetch("NEXO_MODEL")
  sandbox :local
  permissions :approve        # every gated capability needs a human decision
end

class ApprovedWrite < Nexo::Workflow
  sandbox :local
  agent   Scribe
  def call(_p) = { content: run_agent("Write 'hi' to notes.txt").content }
end

The loop is: :approve gate with no decision → Nexo::ApprovalRequiredrun_agent suspends → host renders the pending call → resume(approved:) threads the decision back through the gate.

run = ApprovedWrite.run                       # agent reaches the write gate, run suspends
run.status                                     # => "suspended"
run.state["__suspend__"]["reason"]             # => "approval: notes.txt"
run.state["__approval__"]                      # => { "capability" => "write",
                                               #      "tool" => "notes.txt", "args" => nil }
# "args" carries the tool call arguments only for an MCP-tool approval; a sandbox
# capability gate (write/shell/fetch/search) records "args" => nil — the pending
# call is identified by "capability" + "tool".

# ...a human approves — possibly in another process (resume_later for the AR store):
resumed = ApprovedWrite.resume(run.id, approved: true)
resumed.status                                 # => "done" (the gate allowed the write)

Caveats — read before relying on it

Live example

The live approval-agent flow is runnable in the repo:

NEXO_LIVE=1 NEXO_MODEL=… ruby -Ilib examples/approval_agent.rb

View examples/approval_agent.rb on GitHub →

The state column ships with fresh installs. Apps installed before this feature add it with an additive migration:

rails g nexo:state
rails db:migrate

Honest resume semantics — read this before relying on resume

Resume re-enters #call from the top — Ruby has no transparent continuation capture, so this is not replay:

There is no distinct "resumed" status: resume re-enters execute, so a host sees the existing suspendedrunningdone (or suspended again) transitions over the usual nexo.workflow.status notifications. The boot reconcile_interrupted! sweep leaves "suspended" runs untouched — an intentional pause is never mistaken for an orphaned "running" run.


Next steps

Workflows

The run lifecycle these durability primitives build on.

Rails

Persist runs in a shared store for cross-process resume.

--- URL: https://maquina.app/documentation/nexo/concurrency/ Title: Concurrency ---

Async is entirely optional. Nexo installs and runs synchronously with no async gem present, and only complains if you actually use a concurrency feature. The async gem is a soft dependency — add it yourself when you want fan-out:

gem "async", "~> 2.0"

Two facts make this cheap:


Nexo.concurrent — bounded fan-out

# 100 docs, but never more than 8 provider calls in flight; results in doc order.
results = Nexo.concurrent(max_in_flight: 8) do |c|
  Document.find_each { |d| c.add { SummarizeDocument.run(doc_id: d.id, text: d.body).result } }
end

Every block added with c.add { … } runs inside one async reactor, capped at max_in_flight in flight (an Async::Semaphore) and coordinated by an Async::Barrier. Results come back as an Array in submission order (not completion order). On the first task that raises, that error is re-raised and the remaining in-flight tasks are stopped — errors are never swallowed. max_in_flight defaults to Nexo.config.max_in_flight (8) and is the single most important knob for staying under provider rate limits.

Using Nexo.concurrent with async not installed raises Nexo::MissingDependencyError with install guidance.

Inside a durable workflow, Workflow#checkpoint_all is the workflow-durability flavored sibling of Nexo.concurrent: it drives this same bounded fan-out but persists each step to the run’s state as it lands, so a resume only re-runs what never completed. See Parallel checkpoints in the durable-workflows guide.


Sandboxes::Local offload

Under a reactor, blocking file/subprocess I/O would stall every other fiber. Flip the switch and Sandboxes::Local offloads its read/write/glob/shell to a worker thread:

Nexo.configure { |c| c.concurrency = :async }   # default is :threaded

The decision is driven by config, not by scheduler detection: under :async the blocking block runs on a worker thread so the reactor keeps serving other fibers; under :threaded (the default) it runs inline with zero overhead — byte-for-byte the synchronous behavior. Offloading changes neither return values nor the security properties: the path-escape guard, narrowed ENV, and Timeout-wrapped subprocess are all preserved. (Sandboxes::Virtual is pure memory and Sandboxes::Remote is already HTTP/fiber-friendly — neither needs offload.)


Workflow buffered emit

Each emit normally persists immediately. Under a reactor that per-event DB write blocks the whole loop, so Workflow.run takes a buffer_events: flag (default Nexo.config.buffer_workflow_events, false):

run = SummarizeDocument.run({doc_id: 1, text: body}, buffer_events: true)
# events buffer in memory and flush to the store exactly once, on completion

With buffering on, events accumulate in memory and flush in a single save_events! at the end of the run (on both success and failure). The default (unbuffered) behavior is unchanged.


Running under Rails / a fiber server

Async DB work is the sharp edge. Under a fiber server such as Falcon, many concurrent queries can exhaust the ActiveRecord connection pool, so:

Note that DB work under a reactor is offloaded/pooled, not truly fiber-async — Nexo does not ship a fiber-native DB driver. For server setup (Falcon, the fiber scheduler), see the async guide.


Next steps

Loops

The per-agent engine that concurrency fans out.

Workflows

Structure fan-out work into a finite, inspectable run.

--- URL: https://maquina.app/documentation/ Title: Documentation ---

Maquina is a growing collection of open-source tools extracted from production Rails applications. No complex build pipelines. No framework fatigue.


Why Maquina?

Rails developers who ship alone need tools that work together without adding complexity. Maquina provides:

All projects are MIT licensed, extracted from production apps, and built for the Rails way.


Philosophy

NoBuild

No complex JavaScript build pipelines. Ship CSS and JS directly with importmaps and Tailwind CSS. Every Maquina tool follows this principle.

Single Developer

One developer can build and maintain the entire application. Tools should reduce complexity, not add it.

CRUD Excellence

Most applications are CRUD at their core. Maquina tools make common patterns elegant and maintainable.


Projects

Generators

Rails generators that produce standalone application code with no runtime dependency. Authentication (passwordless or password-based), multi-tenancy, Rack Attack, Solid Queue, error tracking, and job dashboards — all configured in a single command.

bundle add maquina_generators --group development
rails generate maquina:app --auth clave

View Generators Documentation

UI Components

Production-ready components for Rails applications. ERB partials with strict locals, Tailwind CSS 4.0 styling, and Stimulus controllers only where needed.

bundle add maquina_components
rails generate maquina_components:install

Browse Components

Engines

Mountable Rails engines that add complete features to your app. Mount under a backstage path, run the installer, and get production-ready functionality themed with maquina_components.

# config/routes.rb
mount MaquinaNewsletters::Engine => "/backstage/newsletters"

View Engines

AI Tools

MCP servers and Claude Code plugins that let AI assistants understand your Rails projects. Analyze models, routes, schemas, simplify code, and coordinate changes across your editor.

Tool Type Purpose
Rails MCP Server MCP Server Let LLMs analyze your Rails codebase
Neovim MCP Server MCP Server Coordinate buffer changes with AI assistants
Rails Simplifier Plugin Code simplification with 37signals patterns
Rails Upgrade Assistant Plugin Generate upgrade guides for Rails 7.0 through 8.1
Maquina UI Standards Plugin Build consistent UIs with maquina_components

Browse all AI Tools →

Developer Tools

Menu bar apps and CLI utilities for your local development environment.

Tool Purpose
Redis Menu Manage local Redis instances from your menu bar
Mongo Menu Manage local MongoDB instances from your menu bar
Git Continuity Transfer work-in-progress between machines

Browse all Developer Tools →


Community

All projects are on GitHub under the maquina-app organization. Issues, pull requests, and contributions welcome.

--- URL: https://maquina.app/documentation/generators/ Title: Maquina Generators ---

Rails generators that produce standalone application code. No runtime dependency — generate once, own the code forever. Delete the gem when you’re done.


What Is This?

After rails new, every developer follows the same steps: configure authentication, set up request throttling, wire up a job queue, add error tracking. These steps aren’t gaps in the framework — they’re workflow choices that are repetitive and time-consuming.

Maquina Generators make the post-rails new setup as deterministic as the framework itself. The gem is development-only. Everything it produces lives in your app and is yours to modify.

rails new myapp --css tailwind
bundle add maquina-generators --group development
rails generate maquina:app --auth clave
bin/rails db:migrate
bin/dev

Five commands. Auth, multi-tenancy, roles, job queue, error tracking, request protection — all generated, all yours.

Generated app homepage showing authentication, background jobs, error tracking, rate limiting, caching, and real-time features


Quick Start

1. Create a Rails App

rails new myapp --css tailwind
cd myapp

2. Add the Gem

bundle add maquina-generators --group development

3. Run the App Generator

rails generate maquina:app --auth registration

4. Finish Setup

bin/rails db:migrate
bin/rails credentials:edit
# Add: backstage: { username: admin, password: your_password }
bin/dev

Available Generators

Generator Command Purpose
App rails g maquina:app Full application setup (orchestrator)
Clave rails g maquina:clave Passwordless email-code authentication
Registration rails g maquina:registration Password-based auth with accounts and roles
Rack Attack rails g maquina:rack_attack Request protection and IP throttling
Solid Queue rails g maquina:solid_queue Background job processing
Solid Errors rails g maquina:solid_errors Error tracking dashboard
Mission Control rails g maquina:mission_control_jobs Job queue monitoring dashboard

The App Generator

The orchestrator. Runs after rails new and configures a complete, production-ready application in a single command.

rails g maquina:app --auth clave --prefix /admin --port 3000

What It Does

  1. Adds gems — brakeman, standard, rails-i18n, maquina-components, aws-sdk-s3
  2. Creates configs — Procfile.dev, .rubocop.yml, .standard.yml
  3. Configures environments — letter_opener for dev, APPLICATION_HOST for production
  4. Installs Rails features — Action Text, Active Storage, Turbo morphing
  5. Runs auth generator — your choice of clave, registration, or none
  6. Runs sub-generators — rack_attack, solid_queue, mission_control_jobs, solid_errors
  7. Installs Solid adapters — Solid Queue, Solid Cache, Solid Cable, Solid Errors
  8. Installs Maquina Components — UI library ready to use
  9. Creates HomeController — with root route
  10. Sets up multi-database — primary, queue, cache, cable, errors

Options

Option Default Description
--auth none Authentication: none, clave, or registration
--prefix /admin URL prefix for ops dashboards
--port 3000 Development server port

Generated Database Configuration

development:
  primary:
    database: storage/development.sqlite3
  queue:
    database: storage/development_queue.sqlite3
  cache:
    database: storage/development_cache.sqlite3
  cable:
    database: storage/development_cable.sqlite3
  errors:
    database: storage/development_errors.sqlite3

Authentication: Clave (Passwordless)

Complete passwordless authentication using email verification codes. Users receive a 6-digit code via email to sign in — no passwords to manage, no password resets to build.

rails g maquina:clave

What You Get

Models:

Controllers:

Additional:

How It Works

User enters email → receives 6-digit code → enters code → signed in

Multi-Tenancy

Every user belongs to an Account. The first user who creates an account becomes its admin.

# Access anywhere in your app
Current.user          # The signed-in user
Current.account       # The user's account
Current.user.admin?   # Check role

Scoping Queries

class ProjectsController < ApplicationController
  def index
    @projects = Current.account.projects
  end

  def create
    @project = Current.account.projects.build(project_params)
    # ...
  end

  private

  def set_project
    @project = Current.account.projects.find(params[:id])
  end
end

Options

Option Default Description
--skip-views false Skip view templates
--skip-registration false Skip sign-up flow (sign-in only)

Authentication: Registration (Password-Based)

Password-based authentication that builds on Rails 8’s built-in rails generate authentication. Adds multi-tenancy with an Account model, user roles, and a registration flow.

rails g maquina:registration

What It Adds to Rails Auth

Rails 8’s authentication generator gives you login but no signup. Registration adds:

Generated Models

class Account < ApplicationRecord
  has_many :users, dependent: :destroy
  validates :name, presence: true
end

class User < ApplicationRecord
  has_secure_password
  has_many :sessions, dependent: :destroy
  belongs_to :account
  validates :name, presence: true
  enum :role, { member: "member", admin: "admin" }, default: "member"
end

class Current < ActiveSupport::CurrentAttributes
  attribute :session
  delegate :user, to: :session, allow_nil: true
  delegate :account, to: :user, allow_nil: true
end

Registration Flow

class RegistrationsController < ApplicationController
  allow_unauthenticated_access
  rate_limit to: 10, within: 3.minutes, only: :create

  def create
    ActiveRecord::Base.transaction do
      account = Account.create!(name: params[:account_name])
      user = account.users.create!(
        name: params[:name],
        email_address: params[:email_address],
        password: params[:password],
        role: :admin
      )
    end
    start_new_session_for user
    redirect_to root_path
  end
end

Options

Option Default Description
--skip-views false Skip view templates

Rack Attack

Request protection with sensible defaults. Blocks common attack vectors and throttles abusive requests.

rails g maquina:rack_attack

Default Protections

Blocklists:

Throttles:

Safelists:

All rules live in config/initializers/rack_attack.rb. Edit directly.


Solid Queue

Sets up Solid Queue as your Active Job backend with a separate database and Procfile integration.

rails g maquina:solid_queue --database sqlite3

Configuration

# config/solid_queue.yml
default: &default
  dispatchers:
    - polling_interval: 1
      batch_size: 500
  workers:
    - queues: "*"
      threads: 3
      polling_interval: 0.1
  recurring:
    authentication_cleanup:
      class: AuthenticationCleanupJob
      schedule: every day at 3am

Options

Option Default Description
--database sqlite3 Database adapter (sqlite3 or postgresql)

Solid Errors

Error tracking dashboard with custom Tailwind views and HTTP basic auth.

rails g maquina:solid_errors --prefix /admin

What You Get

Authentication

# Checks in order:
# 1. Rails.application.credentials.backstage.username / .password
# 2. ENV["SOLID_ERRORS_USER"] / ENV["SOLID_ERRORS_PASSWORD"]

Set up credentials:

bin/rails credentials:edit
backstage:
  username: admin
  password: your_secure_password

Options

Option Default Description
--prefix required URL prefix (e.g., /admin)
--user-env-var SOLID_ERRORS_USER Custom env var for username
--password-env-var SOLID_ERRORS_PASSWORD Custom env var for password
--copy-views true Include custom Tailwind views

Mission Control Jobs

Job queue monitoring dashboard with custom Tailwind views. 41 view files styled to match your application.

rails g maquina:mission_control_jobs --prefix /admin

What You Get

Options

Option Default Description
--prefix required URL prefix (e.g., /admin)
--user-env-var MISSION_CONTROL_JOBS_USER Custom env var for username
--password-env-var MISSION_CONTROL_JOBS_PASSWORD Custom env var for password
--copy-views true Include custom Tailwind views

Architecture Overview

After running maquina:app --auth clave, your project structure looks like this:

app/
  controllers/
    concerns/
      authentication.rb          # Session management
    sessions_controller.rb       # Sign-in
    registrations_controller.rb  # Sign-up
    home_controller.rb           # Root page
  models/
    account.rb                   # Multi-tenant container
    user.rb                      # Roles + auth
    current.rb                   # Request context
    session.rb                   # Browser sessions
    email_verification.rb        # Verification codes
  mailers/
    verification_mailer.rb       # Email codes
  jobs/
    authentication_cleanup_job.rb  # Daily cleanup

config/
  initializers/
    rack_attack.rb               # Request protection
    solid_errors.rb              # Error tracking auth
    mission_control.rb           # Job dashboard auth
  solid_queue.yml                # Queue configuration

Security Defaults

Ops Dashboards

Both protected with HTTP basic auth using shared backstage credentials.


Role-Based Authorization

Use the role enum to restrict actions:

class ProjectsController < ApplicationController
  before_action :require_admin, only: [:destroy]

  private

  def require_admin
    unless Current.user.admin?
      redirect_to projects_path, alert: t("flash.general.forbidden")
    end
  end
end

Roles:


Customization

All generated code lives in your app. Common customization points:

What Where
Redirect after login app/controllers/concerns/authentication.rbafter_authentication_url
Session duration Change 30.days.from_now in authentication.rb
Code expiration Change 15.minutes.from_now in verification controllers
Resend cooldown EmailVerification::COOLDOWN_MINUTES (default: 15)
View styling Edit view templates directly
Email sender app/mailers/verification_mailer.rb
Translations config/locales/clave.*.yml or registration.*.yml
Rack Attack rules config/initializers/rack_attack.rb
Dashboard credentials bin/rails credentials:editbackstage:
Queue config config/solid_queue.yml

Requirements

The gem has zero runtime dependencies. Add it to your development group, generate your code, and remove it.

# Gemfile
group :development do
  gem "maquina-generators"
end

Next Steps

GitHub Repository

View source code and contribute.

Maquina Components

UI components installed by the app generator.

Rails Simplifier

Keep generated code idiomatic with 37signals patterns.

Rails MCP Server

Give AI visibility into your generated codebase.

--- URL: https://maquina.app/documentation/engines/maquina-newsletters/ Title: Maquina Newsletters ---

A mountable Rails 8 engine for drafting, approving, scheduling, and batch-sending HTML newsletters. Compose with Action Text, gate sends behind an approval step, schedule deliberately, and let a background job deliver in batches — all from a backstage area inside your own app.


What Is This?

Maquina Newsletters is a Rails engine you mount under a backstage path (e.g. /backstage/newsletters). It gives you a complete newsletter lifecycle without bringing in an external service:

The engine renders its own UI (themed with maquina_components) and resolves recipients from a model and scope you configure. Authentication stays in your hands — the engine inherits from a base controller you point it at.


Requirements


Quick Start

1. Add the Gem

# Gemfile
gem "maquina_newsletters", "~> 1.5"
bundle install

2. Mount the Engine

# config/routes.rb
mount MaquinaNewsletters::Engine => "/backstage/newsletters"

3. Run the Installer

bin/rails generate maquina_newsletters:install
bin/rails db:migrate

The installer sets up the engine’s migrations and, if they aren’t already present, runs active_storage:install and action_text:install for you.

4. Add Image Processing

# Gemfile
gem "image_processing", "~> 2.0"
gem "ruby-vips" # or: gem "mini_magick"

Then bundle install and install the system library (see Requirements).

5. Wire Up Tailwind

/* app/assets/tailwind/application.css */
@import "tailwindcss";
@import "../builds/tailwind/maquina_newsletters";
bin/rails tailwindcss:build   # or tailwindcss:watch in development

Keep app/assets/builds/* in .gitignore and rebuild on each machine.

6. Set the Mailer Host

So image URLs in delivered emails are absolute:

# config/environments/production.rb
config.action_mailer.default_url_options = { host: "newsletters.example.com" }

Configuration

Create an initializer to tell the engine who receives newsletters and how it’s protected:

# config/initializers/maquina_newsletters.rb
MaquinaNewsletters.configure do |config|
  # Recipient resolution — which records receive a newsletter.
  config.recipient_model      = "User"          # constantized at use-time
  config.recipient_scope      = :active         # a scope returning a relation
  config.recipient_email_attr = :email_address  # the email column

  # Base controller — see "Authentication" below.
  config.base_controller_class = "BackstageController"

  # Optional HTTP Basic Auth (off by default)
  config.http_basic_auth_enabled  = true
  config.http_basic_auth_user     = ENV["NEWSLETTERS_USER"]
  config.http_basic_auth_password = ENV["NEWSLETTERS_PASSWORD"]
end

If the initializer is absent, the defaults are:

Setting Default
recipient_model "User"
recipient_scope :active
recipient_email_attr :email_address
base_controller_class "ActionController::Base"
HTTP Basic Auth disabled

Authentication

The engine does not provide authentication — that’s the host app’s job. Every engine controller inherits from a base controller you configure by name:

config.base_controller_class = "BackstageController"

Point it at an already-authenticated controller in your app (session checks, etc.) and every engine route is protected automatically.

For apps whose base controller doesn’t authenticate, the engine ships an optional HTTP Basic Auth fallback:

Don’t stack both methods — pick one.


The Newsletter Lifecycle

A newsletter moves through four states:

State What happens
Draft Create and edit content (subject + Action Text body). Saving creates a draft; no send time is set.
Approved Approve a draft when it’s ready. You can’t send from a draft.
Scheduled On an approved issue, set the send timing and batch size.
Sending → Sent A background job delivers. A sending/sent issue can’t be edited.

You can move backward too: Back to draft (from approved/scheduled/sent) and Unschedule (from scheduled back to approved).

Scheduling

The schedule form appears on an approved issue and takes three inputs:

If the chosen date/time has already passed, it auto-rolls forward to the next 30-minute slot and the confirmation says so. Once scheduled, a read-only summary shows Recipients / Scheduled at / Batch size / Sent at.

Send Now & Test Send

Recipients

Recipients are resolved at send time from your configured model and scope (e.g. User.active), minus the per-issue exclusion list. The resulting addresses are downcased, de-duplicated, and sorted for stable batching.


Editors

The host chooses the Action Text editor via config.action_text.editor:

For Lexxy with importmaps:

# config/importmap.rb
pin "lexxy", to: "lexxy.js"
pin "@rails/activestorage", to: "activestorage.esm.js"
// app/javascript/application.js
import * as ActiveStorage from "@rails/activestorage"
import "lexxy"
ActiveStorage.start()
<%# in your layout, after the CSS build %>
<%= stylesheet_link_tag "lexxy" %>

On Rails 8.1, installing the lexxy gem auto-sets config.action_text.editor = :lexxy; set it to :trix to override.


Next Steps

GitHub Repository

Source code, issues, and contribution guidelines.

Maquina Components

The UI library that themes the engine's backstage views.

Maquina Generators

Generate the authentication that protects your backstage.

--- URL: https://maquina.app/documentation/ai-tools/better-stimulus/ Title: Better Stimulus ---

A Claude Code plugin that applies opinionated StimulusJS best practices sourced from betterstimulus.com. Use it whenever writing, reviewing, debugging, or refactoring Stimulus controllers.


What Is This?

A Claude Code skill that:

The skill is triggered by mentions of data-controller, data-action, data-target, data-values, outlets, lifecycle callbacks, Hotwire patterns, or Turbo-and-Stimulus integration.


Quick Start

1. Add the Marketplace

/plugin marketplace add maquina-app/rails-claude-code

2. Install the Plugin

/plugin install better-stimulus@maquina

3. Ask for a Review or Refactor

> Review my dropdown_controller.js against Stimulus best practices
> Refactor this controller to use the Values API instead of instance variables
> Write a Stimulus controller that toggles a panel with late binding

What It Enforces

Area Best Practice
State Use the Values API as the single source of truth, not instance variables
CSS classes Store in static classes, never hardcode strings
Events Declare in data-action markup, not addEventListener in connect()
Dependencies Late binding via Values / Classes / dataset — no hardcoded selectors
Composition Prefer mixins for roles, inheritance only for specializations
Inter-controller Outlets for direct calls, custom events for broadcast
Third-party libs Initialize in connect(), destroy in disconnect()
Turbo Implement teardown() wired to turbo:before-cache when mutating DOM
SRP Split controllers that act on both this.element and targets

A full pre-commit checklist ships with the plugin so Claude can verify any new controller before handing it back.


Usage Examples

Review an Existing Controller

> Review app/javascript/controllers/modal_controller.js

The skill checks for state in instance variables, hardcoded CSS classes, manual event listeners, mixed element/target responsibilities, and missing Turbo teardown.

Refactor Toward the Values API

> Refactor this controller so state lives in values, not this.open

The skill moves state into static values, adds a *ValueChanged callback, and updates markup to include the new data attributes.

Write a New Controller with Late Binding

> Write a toggle controller that uses data-toggle-active-class for styling

The skill produces a controller that reads its active class via the Classes API so the same code works anywhere the markup declares a class.

Apply Hotwire Patterns

> This controller mutates the DOM — add a teardown for Turbo caching

The skill adds a teardown() method and wires it to turbo:before-cache at the application level.


Reference Material

The plugin ships with two reference files the skill consults on demand:

Reference Purpose
references/cookbook.md Copy-paste-ready controllers: faceted search, refresh-when-visible, auto sort, dark mode, radio dropdown
references/solid.md SOLID principles adapted to Stimulus with examples and rationale

Package Contents

better-stimulus/
└── skills/better-stimulus/
    ├── SKILL.md                    # Main skill
    └── references/
        ├── cookbook.md             # Ready-to-use controller patterns
        ├── inter-controller.md     # Outlets, callbacks, custom events
        ├── error-handling.md       # Global error handler
        └── solid.md                # SOLID principles for Stimulus

Team Installation

Add to your project’s .claude/settings.json:

{
  "extraKnownMarketplaces": {
    "maquina": {
      "source": {
        "source": "github",
        "repo": "maquina-app/rails-claude-code"
      }
    }
  },
  "enabledPlugins": [
    "better-stimulus@maquina"
  ]
}

Next Steps

GitHub Repository

View source code and contribute.

betterstimulus.com

The original collection of Stimulus best practices.

Maquina UI Standards

Pair with component-level UI guidance.

--- URL: https://maquina.app/documentation/ai-tools/hotwire-patterns/ Title: Hotwire Patterns ---

A Claude Code skill that gives Claude internals-informed mental models for building and debugging Hotwire applications. Core philosophy: enhance the browser, don’t reinvent it — start by imagining a JS-free, plain-HTML version of every feature, then compose the pages into an integrated UI with Turbo. HTML is the source of truth for state, everywhere.

It complements Better Stimulus (the authority for writing controllers) and Rails Hotwire Driver (which exercises a running Hotwire app from the terminal). This skill is the knowledge layer — how Turbo and Stimulus actually work under the hood.


What Is This?

A Claude Code skill that helps Claude reason about:

It is delivered as a skill (a knowledge module plus focused reference files), not an autonomous agent. Claude reads SKILL.md for the decision frameworks and pulls in a reference only when a specific branch needs it.


The Escalation Ladder

The skill’s central idea: Hotwire is a cost/benefit dial, not a single approach. Choose the cheapest tool that works, and escalate only when the previous rung stops being a good tradeoff:

  1. Turbo Drive + Morphing refreshes — re-render everything server-side; fastest to build.
  2. Turbo Frames — decompose the page; localize updates without touching the rest.
  3. Turbo Stream actions — surgical DOM updates; more precise, more maintenance cost.
  4. Stimulus — small client-side behavior where a server round-trip makes no sense.
  5. Island of a reactive framework or API calls — only for genuinely high-interactivity widgets (maps, editors).

Different parts of one app can sit on different rungs; it all composes.


Quick Start

1. Add the Marketplace

/plugin marketplace add maquina-app/rails-claude-code

2. Install the Plugin

/plugin install hotwire-patterns@maquina

3. Ask About Hotwire

The skill triggers on Hotwire design decisions and symptoms:

> Why does morphing wipe my form?
> My Turbo Stream broadcast isn't arriving
> This system test is flaky
> How do I add Turbo to a legacy app?
> Wrap my app with Hotwire Native

What It Covers

The main SKILL.md holds the decision frameworks and per-topic essentials; each deep dive lives in a reference file Claude loads on demand.

Topic Reference Highlights
Turbo internals (in SKILL.md) Drive/Frames/Streams observers, _top escaping, lazy frames, the frame-id mismatch
Morphing morphing.md idiomorph algorithm, when morph actually runs, scoped exclusion via turbo:before-morph-element
Turbo Cache (in SKILL.md) snapshot cloneNode, preview flashing, data-turbo-temporary, cache-control meta
Broadcasting (in SKILL.md) signed stream names, the background-job debounce, originating-client dedup
Stimulus design stimulus.md callbacks over connect, events vs outlets, the dynamic-forms server-render pattern
Hotwire Native hotwire-native.md native adapter, Path Configuration, Bridge Components, publishing
Testing testing-and-legacy.md flakiness (assert stable state), multi-session broadcast tests, gradual Turbo adoption
Debugging debugging.md unminify Turbo, DOM break-on breakpoints, source landmarks, ActionCable filtering

Custom Stream Actions

A recurring theme: the correct UI update is only known server-side after processing. Custom Turbo Stream actions are the sanctioned way to run backend-driven browser behavior with a constrained, maintainable vocabulary — and they keep you CSP-compatible (no unsafe-inline):

Turbo.StreamActions.log = function () {
  console.log(this.getAttribute("message"))
}

Pair with a Ruby helper module included into Turbo::Streams::TagBuilder. Prefer small, app-specific actions over dropping in large libraries.


Package Contents

hotwire-patterns/
└── skills/hotwire-patterns/
    ├── SKILL.md                    # Overview + decision frameworks + per-topic essentials
    └── references/
        ├── morphing.md             # idiomorph algorithm and gotchas
        ├── stimulus.md             # reusable, composable controller design
        ├── hotwire-native.md       # iOS/Android wrapping
        ├── testing-and-legacy.md   # system tests + gradual Turbo adoption
        └── debugging.md            # internals-informed debugging

Team Installation

Add to your project’s .claude/settings.json:

{
  "extraKnownMarketplaces": {
    "maquina": {
      "source": {
        "source": "github",
        "repo": "maquina-app/rails-claude-code"
      }
    }
  },
  "enabledPlugins": [
    "hotwire-patterns@maquina"
  ]
}

Next Steps

GitHub Repository

View source code and contribute.

Better Stimulus

Write the Stimulus controllers behind your Hotwire UI.

Rails Hotwire Driver

Exercise a running Hotwire app from the terminal.

--- URL: https://maquina.app/documentation/ai-tools/ Title: AI Tools ---

MCP servers, Claude Code plugins, and AI integrations that connect LLMs to your Rails development workflow. Analyze code, access documentation, and coordinate changes across tools.


Available Tools

MCP Servers

Rails MCP Server

Let LLMs introspect models, routes, schemas, and files through dedicated analyzers.

Neovim MCP Server

Read and update Neovim buffers from AI assistants.

Claude Code Plugins

Rails Simplifier

Code simplification following 37signals patterns and One Person Framework.

Rails Upgrade Assistant

Generate upgrade guides for Rails 6.0 through 8.1.

Maquina UI Standards

Build consistent UIs with maquina_components.

MVP Creator

Research, plan, and document MVPs for Rails applications.

Better Stimulus

Opinionated StimulusJS best practices from betterstimulus.com.

Spec-Driven Development

Shape features into specs and hand off to Claude Code.

Rails Security Auditor

Severity-grouped security audit reports for Rails 8.0–8.2.

Rails Hotwire Driver

Drive a running local Rails dev server from the terminal.

Hotwire Patterns

Deep Hotwire internals, decision frameworks, and debugging.


Installing These

Every tool on this page is installed by hand: a gem, a marketplace command, a config file edit. equipr does it mechanically instead. Point it at a marketplace or an Agent Plugins source and it places each skill, command, and MCP server where each agent expects it, across Claude Code, Codex CLI, OpenCode, and Pi.

equipr add https://github.com/coreyhaines31/marketingskills
equipr install marketingskills/marketing-skills:seo-audit

View equipr Documentation


What is MCP?

The Model Context Protocol (MCP) is a standardized way for AI models to interact with their environment. It defines how models request and use tools, access resources, and maintain context.

MCP servers expose tools that AI assistants can call:

# Example: AI assistant analyzes a Rails model
execute_tool(tool_name: "analyze_models", params: { model_name: "User" })

Supported Clients


What are Claude Code Plugins?

Claude Code plugins extend Claude’s capabilities within your development environment. They can be installed from marketplaces and provide:

Installing Plugins

# Add the marketplace
/plugin marketplace add maquina-app/rails-claude-code

# Install a plugin
/plugin install rails-simplifier@maquina

Getting Started

With MCP Servers

1. Install an MCP Server

gem install rails-mcp-server

2. Configure Your Client

For Claude Desktop, add to claude_desktop_config.json:

{
  "mcpServers": {
    "railsMcpServer": {
      "command": "rails-mcp-server"
    }
  }
}

3. Start Using Tools

In Claude Desktop, the MCP server tools become available automatically. Ask Claude to:

With Claude Code Plugins

1. Add the Marketplace

/plugin marketplace add maquina-app/rails-claude-code

2. Install Plugins

/plugin install rails-simplifier@maquina
/plugin install rails-upgrade-assistant@maquina
/plugin install maquina-ui-standards@maquina
/plugin install mvp-creator@maquina
/plugin install better-stimulus@maquina
/plugin install spec-driven-development@maquina
/plugin install rails-security-auditor@maquina
/plugin install rails-hotwire-driver@maquina
/plugin install hotwire-patterns@maquina

3. Use the Plugins

> Simplify the recent changes to the bookings controller
> Upgrade my Rails app to 8.1
> Create a users index view with maquina components

Architecture

MCP Communication

MCP servers communicate via JSON-RPC 2.0:

Mode Use Case
STDIO Direct integration with Claude Desktop
HTTP/SSE Web-based clients, remote access

Plugin Structure

Claude Code plugins follow a standard structure:

plugin-name/
├── agents/           # AI agent definitions
│   └── agent.md
├── skills/           # Knowledge modules
│   └── SKILL.md
├── commands/         # Custom slash commands
│   └── command.md
└── references/       # Documentation
    └── *.md

Tool Reference

Tool Type Purpose
Rails MCP Server MCP Server Code analysis and Ruby execution
Neovim MCP Server MCP Server Editor buffer coordination
Rails Simplifier Plugin Code simplification with 37signals patterns
Rails Upgrade Assistant Plugin Rails 6.0–8.1 upgrade planning
Maquina UI Standards Plugin UI component generation
MVP Creator Plugin MVP research, planning, and documentation
Better Stimulus Plugin StimulusJS best practices and refactoring
Spec-Driven Development Plugin Feature specs, task breakdown, and progress tracking
Rails Security Auditor Plugin Rails 8.0–8.2 security audits with severity grouping
Rails Hotwire Driver Skill Drive a running local Rails dev server from the terminal
Hotwire Patterns Skill Deep Hotwire internals, decision frameworks, and debugging

Team Installation

For consistent tooling across your team, add to .claude/settings.json:

{
  "extraKnownMarketplaces": {
    "maquina": {
      "source": {
        "source": "github",
        "repo": "maquina-app/rails-claude-code"
      }
    }
  },
  "enabledPlugins": [
    "rails-simplifier@maquina",
    "rails-upgrade-assistant@maquina",
    "maquina-ui-standards@maquina",
    "mvp-creator@maquina",
    "better-stimulus@maquina",
    "spec-driven-development@maquina",
    "rails-security-auditor@maquina",
    "rails-hotwire-driver@maquina",
    "hotwire-patterns@maquina"
  ]
}

Commit this file to your repository. Team members get the same plugins automatically.

--- URL: https://maquina.app/documentation/ai-tools/maquina-ui-standards/ Title: Maquina UI Standards ---

A Claude Code plugin that teaches Claude how to build UIs with maquina_components — ERB partials styled with Tailwind CSS 4 and data attributes, inspired by shadcn/ui.


What Is This?

A Claude Code skill that provides:

When installed, Claude generates code that matches your component conventions without back-and-forth corrections.


The Problem It Solves

Without the skill, asking Claude to build a view results in generic Rails patterns:

<div class="card">
  <h2><%= @user.name %></h2>
</div>

With the skill, Claude uses your actual components:

<%= render "components/card" do %>
  <%= render "components/card/header" do %>
    <%= render "components/card/title", text: @user.name %>
  <% end %>
<% end %>

The skill eliminates the “use the card partial, not a div” corrections that slow down AI-assisted development.


Quick Start

1. Add the Marketplace

/plugin marketplace add maquina-app/rails-claude-code

2. Install the Plugin

/plugin install maquina-ui-standards@maquina

3. Start Building

> Create the users index view with a table showing name, email, and status

Requirements

The maquina_components gem must be installed in your Rails application:

bundle add maquina_components
rails generate maquina_components:install

What It Provides

Reference Purpose
Component catalog All available components with ERB examples
Form patterns Validation states, error handling, inline layouts
Layout patterns Sidebar navigation, page headers, content areas
Turbo integration Frames, Streams, and component updates
Spec checklist Accessibility and consistency review criteria
Helpers reference Ruby helpers provided by maquina_components
Stimulus controllers Controllers shipped with the component library
Installation guide Setup for existing and new Rails applications

As of v0.5.0 the plugin ships as a model-invoked skill instead of a subagent: Claude loads the standards into the same conversation where your feature is being built — with your models, controllers, and spec in context — rather than delegating view work to an isolated agent. The trigger works the same way; the skill activates when UI work starts. This release also syncs the references with maquina_components 0.5.x: the Drawer component, the Stats grid, vocabulary aliases (:destructive/:error, size :default), text:/content: leaf parameters, and the ability to attach your own Stimulus controllers to any component (data-controller values concatenate instead of being replaced).

On maquina_components 0.7.1. The bundled references are verified against 0.7.1: the token layer, engine CSS in @layer components, the drawer and sidebar parts, and the fact that utility classes passed through css_classes: now win where they used to be swallowed. They also carry the 0.7.0 changes — the collapse_after deprecation on responsive_breadcrumbs, the icon-override boundary, sidebar inert, and the rule never to transition outline-color in a component of your own — and the 0.7.1 ones: form examples now set aria-invalid, which is what drives the error state since the engine stopped inferring it from :invalid. Theming and Upgrading remain the canonical source if the two ever disagree.


Usage Examples

Create a View

> Create the users index view with a table showing name, email, and status

Claude generates:

<%= render "components/page_header", title: "Users", description: "Manage team members" %>

<%= render "components/card" do %>
  <%= render "components/card/content" do %>
    <table data-component="table">
      <thead>
        <tr>
          <th>Name</th>
          <th>Email</th>
          <th>Status</th>
        </tr>
      </thead>
      <tbody>
        <% @users.each do |user| %>
          <tr>
            <td><%= user.name %></td>
            <td><%= user.email %></td>
            <td>
              <%= render "components/badge",
                variant: user.active? ? :success : :secondary do %>
                <%= user.active? ? "Active" : "Inactive" %>
              <% end %>
            </td>
          </tr>
        <% end %>
      </tbody>
    </table>
  <% end %>
<% end %>

Implement a Form

> Implement the project form with name, description, and a framework combobox

Claude generates:

<%= form_with model: @project do |form| %>
  <div class="space-y-4">
    <div>
      <%= form.label :name %>
      <%= form.text_field :name, data: { component: "input" } %>
    </div>

    <div>
      <%= form.label :description %>
      <%= form.text_area :description, data: { component: "textarea" }, rows: 4 %>
    </div>

    <div>
      <%= form.label :framework_id %>
      <%= combobox_simple name: "project[framework_id]",
            value: @project.framework_id,
            options: @frameworks.map { |f| { value: f.id, label: f.name } },
            placeholder: "Select a framework" %>
    </div>

    <div class="flex justify-end gap-2">
      <%= link_to "Cancel", projects_path,
        data: { component: "button", variant: "outline" } %>
      <%= form.submit "Save",
        data: { component: "button", variant: "primary" } %>
    </div>
  </div>
<% end %>

Review Existing Code

> Review this view against the maquina UI standards and suggest improvements

Claude checks for:


Component Patterns

Partial Components

Components rendered as partials with strict locals:

<%# Card with header and content %>
<%= render "components/card" do %>
  <%= render "components/card/header" do %>
    <%= render "components/card/title", text: "Appointments" %>
    <%= render "components/card/description", text: "Manage your schedule" %>
  <% end %>
  <%= render "components/card/content" do %>
    <!-- Content here -->
  <% end %>
<% end %>

Data Attribute Components

Form elements and buttons use data attributes for styling:

<%# Text input %>
<%= form.text_field :name, data: { component: "input" } %>

<%# Button %>
<%= link_to "Edit", edit_path,
  data: { component: "button", variant: "outline", size: "sm" } %>

<%# Badge %>
<%= render "components/badge", variant: :success do %>
  Active
<% end %>

Layout Patterns

<%# Sidebar layout %>
<%= render "components/sidebar/provider", state: sidebar_state do %>
  <%= render "components/sidebar" do %>
    <%= render "components/sidebar/header" do %>
      <!-- Logo -->
    <% end %>
    <%= render "components/sidebar/content" do %>
      <%= render "components/sidebar/group", title: "Navigation" do %>
        <%= render "components/sidebar/menu" do %>
          <%= render "components/sidebar/menu_item" do %>
            <%= render "components/sidebar/menu_button",
              url: dashboard_path,
              icon_name: :home,
              title: "Dashboard",
              active: current_page?(dashboard_path) %>
          <% end %>
        <% end %>
      <% end %>
    <% end %>
  <% end %>

  <%= render "components/sidebar/inset" do %>
    <%= yield %>
  <% end %>
<% end %>

Package Contents

maquina-ui-standards/
├── skills/ui/SKILL.md                 # Model-invoked skill
├── QUICKSTART.md                      # Quick reference for humans
└── references/
    ├── component-catalog.md           # All available components
    ├── form-patterns.md               # Validation, error handling
    ├── layout-patterns.md             # Pages, dashboards
    ├── turbo-integration.md           # Frames, streams
    ├── spec-checklist.md              # Accessibility, consistency
    ├── helpers-reference.md           # Ruby helpers provided by maquina_components
    ├── stimulus-controllers.md        # Controllers shipped with the library
    └── installation-guide.md          # Setup for existing and new Rails apps

Team Installation

Add to your project’s .claude/settings.json:

{
  "extraKnownMarketplaces": {
    "maquina": {
      "source": {
        "source": "github",
        "repo": "maquina-app/rails-claude-code"
      }
    }
  },
  "enabledPlugins": [
    "maquina-ui-standards@maquina"
  ]
}

Alternative: Claude Skill Installation

If you prefer using Claude Skills instead of the plugin system, copy the skill to your project:

mkdir -p .claude/skills
# Copy from the rails-claude-code repository
cp -r maquina-ui-standards/skills/ui .claude/skills/maquina-ui-standards
cp -r maquina-ui-standards/references .claude/skills/

Then reference it in your CLAUDE.md:

## UI Components

This project uses maquina_components for UI. Before implementing views,
forms, or interactive components, read the UI standards skill:

.claude/skills/maquina-ui-standards/SKILL.md

Always consult the skill when:
- Creating or modifying views
- Implementing forms
- Adding interactive components
- Building layouts with sidebar/header patterns
- Working with Turbo Streams that update UI

Next Steps

GitHub Repository

View source code and contribute.

Component Documentation

Browse all maquina_components.

Announcement Post

Read about the skill's development.

maquina_components Gem

Install the component library.

--- URL: https://maquina.app/documentation/ai-tools/mvp-creator/ Title: MVP Creator ---

A Claude Code plugin that creates comprehensive MVP documentation for Rails applications through guided research and discovery. Go from idea to implementation-ready deliverables.


What Is This?

A Claude Code skill that:


The Workflow

Step What Happens
1. Topic/Idea You describe your app concept or business idea
2. Research Skill researches competitors, market, and technology landscape
3. Discovery Questions Interactive Q&A to refine scope, audience, and priorities
4. Generate Deliverables Five documents produced in sequence
5. Handoff Ready for Spec-Driven Development to begin implementation

The skill walks you through each step interactively. You provide context and make decisions — the skill handles research, structure, and writing.


Quick Start

1. Add the Marketplace

/plugin marketplace add maquina-app/rails-claude-code

2. Install the Plugin

/plugin install mvp-creator@maquina

3. Start Creating

> I have an idea for a project management app

Deliverables

Every MVP session produces five documents:

Deliverable Description
Research Report Competitor analysis, market overview, feature comparison
MVP Business Plan Vision, feature priorities, user flows, success metrics
Brand Guide Logo direction, colors, typography, components, voice
Technical Guide Architecture, patterns, data models, code style
Claude Setup CLAUDE.md, .mcp.json, and commands for Claude Desktop/Code

Research Report

The skill searches the web for competitors, analyzes their features, pricing, and positioning. You get a structured comparison that informs every subsequent deliverable.

MVP Business Plan

Defines what to build first. Includes prioritized features, user stories, and success metrics. Scoped to what a single developer can ship.

Brand Guide

Visual identity decisions: color palette, typography, component styling, and tone of voice. Ready to apply when building the UI.

Technical Guide

Rails-specific architecture: models, associations, authentication approach, API patterns, and testing strategy. Follows 37signals conventions.

Claude Setup

Pre-configured CLAUDE.md with project context, .mcp.json for MCP server integration, and custom commands. Drop these into your new Rails project and start building with full AI context.


Usage Examples

Start from an Idea

> I have an idea for a project management app

The skill begins with research, then asks discovery questions to shape the MVP.

Plan a SaaS Product

> Help me plan a SaaS for freelancers

The skill treats this as a full MVP session — research, discovery, and all five deliverables.

Research Competitors

> Research competitors for a booking system

The skill focuses on the research phase and produces a detailed competitor analysis.

Create a Business Plan

> Create a business plan for my app idea

Skips research if you already know the market. Goes straight to discovery and deliverables.

Design a Brand

> Design a brand for my Rails project

Generates the Brand Guide deliverable with color palette, typography, and voice guidelines.


Package Contents

mvp-creator/
├── QUICKSTART.md                        # Quick reference
└── skills/mvp-creator/
    ├── SKILL.md                         # Main skill
    ├── scripts/
    │   └── init.sh                      # Project initialization
    └── references/
    ├── rails-philosophy.md              # Rails conventions and principles
    ├── rails-ui-patterns.md             # UI design patterns
    ├── rails-api-patterns.md            # API design patterns
    ├── rails-implementation-patterns.md # Implementation guidelines
    └── deliverable-templates/           # Templates for all 5 deliverables
        ├── research-report.md
        ├── mvp-business-plan.md
        ├── brand-guide.md
        ├── technical-guide.md
        └── claude-setup.md

Team Installation

Add to your project’s .claude/settings.json:

{
  "extraKnownMarketplaces": {
    "maquina": {
      "source": {
        "source": "github",
        "repo": "maquina-app/rails-claude-code"
      }
    }
  },
  "enabledPlugins": [
    "mvp-creator@maquina"
  ]
}

Next Steps

GitHub Repository

View source code and contribute.

Spec-Driven Development

Continue from MVP to implementation with SDD.

Announcement Post

Watch the full 40-minute walkthrough video.

--- URL: https://maquina.app/documentation/ai-tools/nvim-mcp-server/ Title: Neovim MCP Server ---

A Ruby implementation of a Model Context Protocol (MCP) server for Neovim integration. Coordinate file changes between your editor and AI assistants by reading and updating Neovim buffers directly.


What is MCP?

The Model Context Protocol (MCP) is a standardized way for AI models to interact with their environment. It defines how models request and use tools, access resources, and maintain context.

The Neovim MCP Server exposes your editor’s buffers to AI assistants, enabling them to read file contents and push changes directly into your editing session.


Features


Quick Start

1. Install the Gem

gem install nvim-mcp-server

2. Configure Neovim

Add to your init.lua to start the RPC server:

-- Start the Neovim RPC server on a socket
vim.fn.serverstart('/tmp/nvim-mcp.sock')

Or start Neovim with a socket:

nvim --listen /tmp/nvim-mcp.sock

3. Configure Claude Desktop

Add to claude_desktop_config.json:

{
  "mcpServers": {
    "nvimMcpServer": {
      "command": "nvim-mcp-server",
      "args": ["--socket", "/tmp/nvim-mcp.sock"]
    }
  }
}

Available Tools

The server provides 2 tools for buffer management.

Tool Description
get_project_buffers Get contents of all open buffers
update_buffer Update a buffer with new content

Get Project Buffers

Returns the contents of all buffers currently open in Neovim:

get_project_buffers()

Response includes file paths and their contents, allowing AI assistants to understand your current working context.

Update Buffer

Updates a specific buffer with new content:

update_buffer(file_path: "/path/to/file.rb", content: "new content here")

The changes appear immediately in Neovim, ready for you to review, modify, or save.


Server Modes

STDIO Mode (Default)

For direct integration with Claude Desktop:

nvim-mcp-server --socket /tmp/nvim-mcp.sock

HTTP Mode

For HTTP endpoints with JSON-RPC and SSE:

nvim-mcp-server --mode http --socket /tmp/nvim-mcp.sock
nvim-mcp-server --mode http --socket /tmp/nvim-mcp.sock -p 8080

Endpoints:


Neovim Configuration

Socket Setup

The MCP server communicates with Neovim via RPC over a Unix socket. Configure Neovim to listen:

Option 1: In init.lua (recommended)

-- Always start the socket server
vim.fn.serverstart('/tmp/nvim-mcp.sock')

Option 2: Shell alias

alias nvim='nvim --listen /tmp/nvim-mcp.sock'

Option 3: Per-session

nvim --listen /tmp/nvim-mcp.sock

Multiple Neovim Instances

For multiple Neovim instances, use unique socket paths:

-- In init.lua
local socket_path = '/tmp/nvim-mcp-' .. vim.fn.getpid() .. '.sock'
vim.fn.serverstart(socket_path)
print('Neovim socket: ' .. socket_path)

Then specify the socket when starting the MCP server:

nvim-mcp-server --socket /tmp/nvim-mcp-12345.sock

Ruby Version Manager Users

Claude Desktop bypasses version manager initialization. Use the Ruby shim path:

{
  "mcpServers": {
    "nvimMcpServer": {
      "command": "/home/your_user/.rbenv/shims/ruby",
      "args": [
        "/path/to/nvim-mcp-server/exe/nvim-mcp-server",
        "--socket",
        "/tmp/nvim-mcp.sock"
      ]
    }
  }
}

Testing and Debugging

Use MCP Inspector to test the server:

npm -g install @modelcontextprotocol/inspector
npx @modelcontextprotocol/inspector nvim-mcp-server --socket /tmp/nvim-mcp.sock

The Inspector UI lets you:

Verify Neovim Socket

Check that Neovim is listening:

# Should show the socket file
ls -la /tmp/nvim-mcp.sock

From within Neovim, verify the server address:

:echo v:servername

Use Cases

Code Review Workflow

  1. Open files in Neovim
  2. Ask Claude to review the open buffers
  3. Claude reads via get_project_buffers
  4. Claude suggests changes via update_buffer
  5. Review changes in Neovim before saving

AI-Assisted Editing

Combine with other MCP servers for powerful workflows:


Next Steps

GitHub Repository

Source code, issues, and contribution guidelines.

Rails MCP Server

Analyze models, routes, and schemas in your Rails projects.

--- URL: https://maquina.app/documentation/ai-tools/rails-hotwire-driver/ Title: Rails Hotwire Driver ---

A Claude Code skill that drives a running local Rails dev server from the shell — no browser required. Log in (including OTP/magic-link codes read straight from the log), submit ERB forms with the correct CSRF token, inspect Turbo Stream responses, and trace any request through the development log by its request id.

It is the runtime complement to the Rails MCP Server, which only reads code statically. This skill adds live interaction with a real, running app.


What Is This?

A Claude Code skill that lets Claude:

It is delivered as a skill (knowledge module plus shell scripts), not an autonomous agent. Claude reads SKILL.md and runs the scripts in scripts/ against your local app.


When It Fits (and When It Doesn’t)

Good fit: ERB + Hotwire apps with minimal JavaScript. The server renders HTML and text/vnd.turbo-stream.html; you are verifying that server-rendered contract.

It does not execute JavaScript. No Stimulus controllers run, no DOM morphing, no requestSubmit, no ActionCable-broadcast rendering. You can see a broadcast happen in the log (via request-id correlation), but not its DOM effect. For those cases, pair it with a browser-driving tool like the Playwright MCP — the session bridge means you only log in once.


Quick Start

1. Add the Marketplace

/plugin marketplace add maquina-app/rails-claude-code

2. Install the Plugin

/plugin install rails-hotwire-driver@maquina

3. Drive Your App

With your Rails app running locally (e.g. bin/rails s), just ask:

> Log in as me@example.com and open the dashboard
> Submit the new post form and show me which turbo-streams came back
> Read the OTP code from the log and finish the login
> Trace request abc-123 through the development log

Prerequisites

Confirm these before driving:

  1. The app is running locally in development, and you know its port. Set BASE_URL (default http://localhost:3000). The scripts refuse any non-local host — allowed: localhost, loopback IPs, and any *.localhost name.
  2. Nokogiri is available — it ships with essentially every Rails bundle. Run the Ruby scripts via the project bundle (bundle exec ruby ...).
  3. Recommended: request-id tagging for best log correlation. In config/environments/development.rb:
    config.log_tags = [ :request_id ]
    

    Without it, readlog.sh request falls back to a context window instead of an exact filter — still useful, just noisier.

These scripts only ever talk to a local server and only read the development log. Reading secrets like OTP codes out of a log is a development-only affordance — readlog.sh refuses any path containing production.


The Scripts

All live in scripts/. A shared cookie jar at ./.hotwire/cookies.txt carries the session across calls.

Script Purpose
req.sh One HTTP request with cookies persisted. Prints response headers (with X-Request-Id, Set-Cookie redacted) and the body.
submit_form.rb Submit a form with the correct CSRF token. GETs the page, reads hidden inputs including authenticity_token, merges your fields, honors Rails’ _method field.
readlog.sh Read the dev log safely — tail, grep, request <id>, or otp.
flow.sh Full login → OTP → action in one command, all sharing the cookie jar.
jar_to_storage.rb / storage_to_jar.rb Bridge the curl session to/from Playwright storageState.

req.sh — one request, cookies persisted

req.sh GET  /products
req.sh GET  /cart turbo            # Accept: text/vnd.turbo-stream.html
req.sh GET  /messages frame:inbox  # Turbo-Frame: inbox (load a lazy frame)
req.sh POST /cart/add 'product_id=1&qty=2'

submit_form.rb — the right CSRF token, every time

This is the tool for any POST/PUT/PATCH/DELETE through an ERB form. It eliminates the single most common hand-driving failure — a missing or stale CSRF token.

bundle exec ruby scripts/submit_form.rb /session/new "email=me@x.com" "password=secret"
bundle exec ruby scripts/submit_form.rb /posts/new "form#new_post" "post[title]=Hi"

It reports status, X-Request-Id, any redirect Location, and — for turbo-stream responses — a parsed list of action #target pairs.

readlog.sh — read the dev log safely

readlog.sh tail 200
readlog.sh grep 'SQL|SELECT' 500
readlog.sh request <x-request-id>   # exact lines for one request (needs log_tags)
readlog.sh otp                      # grep common OTP / magic-link / token patterns

flow.sh — login → OTP → action in one command

Orchestrates the other three: submits the login form (CSRF handled), reads the OTP from the log scoped to the login’s request id (not a blind grep), submits the OTP, then optionally performs one authenticated action.

# OTP / magic-link login, then hit an authenticated page:
flow.sh --email me@x.com --password secret \
        --login-path /session/new \
        --otp-path /session/otp --otp-field code \
        --then-path /dashboard --then-method GET

# Password-only (omit --otp-path to skip the OTP steps):
flow.sh --email me@x.com --password secret --then-path /account

# Authenticated POST through a form (CSRF auto-handled):
flow.sh --email me@x.com --otp-path /session/otp \
        --then-path /posts/new --then-method POST --then-fields 'post[title]=Hi'

Core Workflows

In development, the mailer/notifier writes the code to the log rather than sending real email. flow.sh does this in one command; manually the steps are:

  1. Trigger it: submit_form.rb /session/new "email=...".
  2. Read the code: take the X-Request-Id from step 1, run readlog.sh request <id>, and extract the code.
  3. Submit it: submit_form.rb /otp "code=123456".

Verify a Turbo Stream

  1. req.sh POST /cart/add 'product_id=1' turbo (or submit_form.rb for CSRF forms).
  2. Read the parsed action #target list to confirm the server returned the streams you expected (e.g. replace #cart_summary, append #flash).
  3. Correlate render details with readlog.sh request <X-Request-Id> — which partials rendered, what SQL ran.

Trace one request end to end

Any req.sh/submit_form.rb call prints X-Request-Id. Feed it to readlog.sh request <id> for a clean, single-request slice of the log — the most reliable way to see params, SQL, partial renders, and errors without log noise.


Pairing with Playwright

This skill verifies the server’s contract (turbo-stream actions, SQL, logs, the raw HTML before JS runs). Playwright verifies client behavior (did Stimulus wire up, did the stream actually mutate the DOM, did a lazy frame load). They’re complementary — the session bridge means you log in only once.

curl → Playwright (the common case)

Authenticate fast with the OTP-from-log trick, then hand the logged-in session to a real browser.

flow.sh --email me@x.com --otp-path /session/otp --then-path /
ruby jar_to_storage.rb --origin http://fragua.localhost > state.json
# then: npx @playwright/mcp@latest --storage-state state.json

Playwright → curl (reverse)

If a login is too JS-heavy for curl to replay (OAuth popup, Stimulus-driven form), let Playwright do it through the real UI, export its session, and drop back to the fast curl + log tools.

# in Playwright: await context.storageState({ path: 'state.json' })
ruby storage_to_jar.rb --in state.json     # writes ./.hotwire/cookies.txt
req.sh GET /dashboard                       # now authenticated

The bridge scripts emit the standard storageState format, so they work with the Playwright MCP, the Node test runner, or playwright-ruby-client.


kamal-proxy and *.localhost Hosts

If you front your apps with kamal-proxy and reach them at names like http://fragua.localhost, set BASE_URL=http://fragua.localhost (with the port if not 80). The proxy routes by the Host header, which curl and Net::HTTP send automatically.

*.localhost resolves to loopback on macOS and most browsers, but not always on Linux. Force resolution with RESOLVE:

RESOLVE=1 BASE_URL=http://fragua.localhost:80 req.sh GET /
# connects to 127.0.0.1 but still sends Host: fragua.localhost

RESOLVE works for both req.sh and submit_form.rb; the Host header is preserved for routing either way. Point LOG_FILE at the specific app’s log/development.log, since each app under the proxy has its own log.


Configuration

Set via environment variables:

Variable Default Purpose
BASE_URL http://localhost:3000 Target server. For kamal-proxy use the routed name.
RESOLVE (off) Force the host to resolve to an IP. RESOLVE=1127.0.0.1; RESOLVE=<ip> → that IP.
JAR ./.hotwire/cookies.txt Cookie jar path.
LOG_FILE ./log/development.log Log to read (point at the specific app’s log).
MAX_BYTES 100000 Response body cap for req.sh.

Guardrails

These are deliberate — don’t weaken them:


Package Contents

rails-hotwire-driver/
└── skills/
    └── rails-hotwire-driver/
        ├── SKILL.md                 # Skill knowledge module
        └── scripts/
            ├── req.sh               # One HTTP request, cookies persisted
            ├── submit_form.rb       # CSRF-correct form submit
            ├── readlog.sh           # Safe dev-log reader
            ├── flow.sh              # login → OTP → action
            ├── jar_to_storage.rb    # curl jar → Playwright storageState
            └── storage_to_jar.rb    # Playwright storageState → curl jar

Team Installation

Add to your project’s .claude/settings.json:

{
  "extraKnownMarketplaces": {
    "maquina": {
      "source": {
        "source": "github",
        "repo": "maquina-app/rails-claude-code"
      }
    }
  },
  "enabledPlugins": [
    "rails-hotwire-driver@maquina"
  ]
}

Next Steps

GitHub Repository

View source code and contribute.

Rails MCP Server

Pair static code analysis with live runtime interaction.

Better Stimulus

Write the Stimulus controllers behind your Hotwire UI.

--- URL: https://maquina.app/blog/2026/08/equipr-cross-agent-skill-manager/ Title: equipr: Cross-Agent Skill and MCP Server Manager ---

equipr installs skills, commands, and MCP servers from a marketplace or an Agent Plugins source into whichever coding agents are present on a machine: Claude Code, Codex, OpenCode, and Pi. It works through each agent’s own personal config surfaces rather than a plugin system. It is a single Go binary, MIT licensed, and available now via Homebrew or as a standalone install script.

The problem it addresses

Claude Code has a marketplace mechanism for distributing and updating skills that none of the other three agents come close to matching. Codex, OpenCode, and Pi have no built-in equivalent. The alternatives are copying a skill’s files by hand, which then has to be kept current on its own since nothing does that automatically, or installing an npm package for the skills some maintainers publish that way. The Agent Plugins specification exists to standardize the package format itself, but it does not yet define how any individual agent installs from a source or keeps that installation up to date. That part is still left to each agent’s own tooling, or to something else.

The same gap shows up again with isolated per-agent containers, of the kind Fragua uses: each container needs the same sources installed, and each one is a separate place that update has to reach.

What equipr resolves

equipr treats a source as one of two shapes. A repository with .claude-plugin/marketplace.json at its root is a marketplace holding one or more plugins. A repository with plugin.json at its root is a single Agent Plugins-conformant package. Both resolve to the same internal model, addressed the same way:

<source-id>/<plugin>:<component>
$ equipr add https://github.com/coreyhaines31/marketingskills
Fetching https://github.com/coreyhaines31/marketingskills
Added marketingskills (marketplace, fetched via git) with 1 plugin(s)
  - marketing-skills 2.10.0

$ equipr list
marketingskills     marketplace
  marketing-skills  2.10.0  49 skills

$ equipr install marketingskills/marketing-skills:seo-audit --yes
Installed 1 component(s) into 3 agent(s):
  [claude-code] seo-audit (copy -> ~/.claude/skills/seo-audit)
  [opencode] seo-audit (symlink -> ~/.config/opencode/skills/seo-audit)
  [pi] seo-audit (symlink -> ~/.pi/agent/skills/seo-audit)

equipr status reports whether each recorded install still matches its source. equipr update re-fetches every registered source and re-applies anything that changed. equipr doctor reports broken symlinks and orphaned records.

Where each agent differs

The four agents agree on very little beyond the skill file format itself. A SKILL.md is the one point of real convergence across all four. Everywhere else, they differ:

Agent Skills directory MCP config
Claude Code ~/.claude/skills/ ~/.claude.json
Codex ~/.codex/skills/ ~/.codex/config.toml
OpenCode ~/.config/opencode/skills/ ~/.config/opencode/opencode.json
Pi ~/.pi/agent/skills/ ~/.config/mcp/mcp.json

Three different config formats, and OpenCode’s MCP schema is not merely a different filename: it requires an explicit type and a command array where Claude Code infers both from a single string. equipr’s install step translates a source’s MCP configuration into each target’s native shape rather than writing one format everywhere.

Installing into each agent

equipr places skill and command files where each agent expects them, and merges MCP servers into each agent’s existing config without disturbing anything else already there: no unrelated key is touched, no unrelated setting is reformatted. Installing is the same command regardless of which agent it’s going into; equipr absorbs the difference in how each one stores things.

npm sources without an npm install

equipr accepts an npm package as a source. It does not run npm install. It runs npm pack, extracts the resulting tarball into its own cache alongside git and archive sources, and reads plugin.json, skills/, commands/, and mcp.json out of the result. No dependency tree is installed alongside it, and nothing registers as a plugin or a global package in the process.

What equipr does not do

equipr never registers itself as a native plugin inside any agent: no entry in Claude Code’s /plugin list, no npm-global registration, nothing to unregister if the binary is removed. It writes to personal config directories and stops.

What this reinforced

Treating each agent’s personal config surface as the integration point, rather than any agent’s own plugin system, is what let one tool support four incompatible agents from a single codebase. The harder requirement turned out to be the opposite of adding capability: writing less to any given file, and writing it more carefully, specifically the single-key-merge discipline for MCP config. That constraint did more to make the tool trustworthy than any feature did.

Install

curl -fsSL https://github.com/maquina-app/equipr/releases/latest/download/install.sh | sh

Checksum-verified, installs to /usr/local/bin when writable, ~/.local/bin otherwise. A Homebrew tap is also available: brew install maquina-app/tap/equipr.

The equipr documentation covers every command, every flag, where each file lands per agent, and exit codes for scripting. Source is on GitHub, MIT licensed.

Maquina’s own Claude Code plugins are distributed as a marketplace equipr can install from: see AI Tools.

--- URL: https://maquina.app/blog/2026/08/rails-mcp-server-2-0-0-removing-execute-ruby/ Title: Why I Removed execute_ruby from Rails MCP Server ---

I built the first version of Rails MCP Server before Claude Code, Codex, and tools like them were the popular way to work with AI on a codebase. Back then, Claude Desktop could talk to me and read whatever I pasted into the chat. Nothing else. If I wanted it to see a model’s associations, check a route, or run a quick query against my own data, I copied and pasted. Every question meant leaving the conversation, going to the terminal, and bringing the answer back by hand.

That’s the itch Rails MCP Server scratched: let Claude Desktop introspect a Rails project directly (routes, schema, models) without me being the copy-paste layer between every question and its answer. A companion tool, nvim-mcp-server, closed the other half of the loop, letting Claude Desktop write code changes straight into my Neovim buffers instead of me pasting a diff and applying it by hand.

execute_ruby made sense inside that setup. Claude Desktop had no way to touch my filesystem or run anything on its own. The MCP server was its only hands. Giving it a tool that could run real Ruby inside my running Rails app meant one call could answer what would otherwise take a dozen round trips: an ad-hoc scope, or a quick Model.find to check a hunch. It was the sharpest tool in the server, and it earned its place.

What changed

That gap doesn’t exist for most people building with AI anymore. Claude Code, Codex, and the agentic coding tools that followed sit directly on top of the codebase. They already have a shell and the same Ruby your app runs on. If an agent wants to check User.where(inactive: true).count, it doesn’t need a special MCP tool for that. It can just run it, the way I would from my own terminal.

That’s most of the reasoning that justified execute_ruby, gone. The tool wasn’t wrong when I built it. It answered a real constraint, and that constraint has largely gone away.

Hardening a tool I was already rethinking

I didn’t arrive here in one step. Yesterday’s 1.6.0 release closed several real bypasses in the execute_ruby sandbox: file reads that slipped past validation and symlinks that walked out of the project, on top of database writes that weren’t actually being rolled back. Today’s 1.6.1 went further: a researcher at Pluto Security responsibly disclosed a command-execution path through require "pty", and the fix restricted require to a small allowlist of data-only libraries, cutting off that path along with a few adjacent ones.

Both releases were worth shipping on their own. But writing 1.6.1 is what made the actual problem visible to me: I was patching individual escapes out of a tool whose entire premise is running caller-supplied Ruby with the privileges of the server process. A static scan and some runtime overrides can raise the floor, but they were never going to be a hard boundary. Path validation, transaction rollback, the confirm_risky gate: every fence I’ve put around execute_ruby has been best-effort, and 1.6.0 said so plainly. Best-effort is a reasonable trade when a tool is filling a real gap. It stops paying off once that’s no longer true.

What 2.0.0 does

Version 2.0.0 removes execute_ruby outright, rather than hardening it again. The server is introspection-only now, and its dedicated analyzers already cover the ground execute_ruby was built for:

Instead of execute_ruby for… Use
Reading a file get_file
Finding files list_files
Routes, schema, models, controllers, env, structure get_routes, get_schema, analyze_models, analyze_controller_views, analyze_environment_config, project_info

Ad-hoc data queries (a custom scope, a one-off count) are the one thing this doesn’t replace, on purpose. That was always the part of execute_ruby doing the most work and carrying the most risk, and it’s exactly the part an agent sitting on top of your code no longer needs a server tool for.

Bootstrap tools drop from four to three: switch_project, search_tools, execute_tool. The internal analyzers haven’t changed. They’re still discovered through search_tools and invoked through execute_tool, same as before.

If you’re on a client without direct code execution and still want execute_ruby, the 1.6.x line keeps it, now with the 1.6.1 hardening. It isn’t disappearing; it’s just not where the project is headed.

Upgrading

gem update rails-mcp-server

If you have execute_ruby wired into a client’s tool config, remove it from there. Replace file reads with get_file and globs with list_files. Everything else keeps working the way it did.

For new installations:

gem install rails-mcp-server
rails-mcp-config

Where this leaves the project

A tool that runs arbitrary code is always going to be the most interesting line in a security report, no matter how many layers sit around it. I’d rather ship a Rails MCP Server that doesn’t have that line at all than one that keeps explaining, release after release, why the latest patch finally closes the gap. What’s gone is the one tool whose risk had stopped being worth what it saved me.

Thanks again to Pluto Security for the responsible disclosure that shaped both 1.6.1 and this release. If you find a gap, the issue tracker is open.

--- URL: https://maquina.app/blog/2026/08/rails-mcp-server-1-6-0-sandbox-hardening/ Title: Rails MCP Server 1.6.0: Making "Read-Only" Actually Mean Read-Only ---

The execute_ruby tool lets an AI model run Ruby in the context of your Rails application. That is genuinely useful. One call can answer a question that would otherwise take a dozen tool round-trips, and it is also the single most dangerous thing this server does. The tool advertises itself as read-only. Version 1.6.0 is about making that promise closer to true.

This release started with an uncomfortable review of the sandbox. What I found is that “read-only” had holes you could walk through without trying. So 1.6.0 closes them, adds several layers of defense behind them, and, importantly, is honest about what the sandbox is and isn’t.

What the sandbox actually is

Worth stating plainly, because it frames everything below: execute_ruby runs your code through bin/rails runner. That means real Ruby, with your full application loaded and a live database connection. The sandbox is a combination of static analysis (a scan for dangerous patterns) and runtime overrides of File, IO, Dir, and Kernel.

That is defense-in-depth, not a locked box. It raises the floor; it is not an isolation boundary. Keeping that distinction visible is part of what 1.6.0 fixes. The previous framing implied more safety than the implementation delivered.

The holes that were closed

File reads leaked through the siblings. The sandbox overrode File.read and File.open, but not File.readlines, File.binread, File.foreach, or any of the IO equivalents. Those are separate entry points, and they read arbitrary paths:

# Blocked before 1.6.0:
File.read("/etc/passwd")     # => PATH ERROR

# Wide open before 1.6.0:
IO.read("/etc/passwd")       # read anything
File.readlines("/etc/passwd")

Every read entry point now routes through the same path validation.

Raw readers, still exposed: the overrides kept the originals around as public aliases like File.original_read, so user code could just call the un-sandboxed method directly. Those are gone from the public surface now; the native handles live in private constants the sandbox uses internally.

Symlinks could point out of the project. Path validation expanded paths but never resolved symlinks, so a link inside the project that pointed at /etc/passwd passed the check and then read the target. Validation now resolves the real path first, and the allowlist for system data (the timezone directories Rails needs for Time.zone) is matched against canonical locations so it keeps working on macOS, where /usr/share/zoneinfo is itself a symlink.

Half-blocked ENV: the scan rejected ENV[...] and ENV.fetch, but ENV.to_h, ENV.values_at, and ENV.each walked straight past it and handed over every secret in the process environment. The scan now rejects ENV access broadly, while still leaving Rails.env alone.

The layers added behind them

Closing bypasses is necessary but not sufficient. A blocklist can always be out-metaprogrammed. So 1.6.0 adds controls that don’t depend on catching every trick.

Database writes are rolled back. This is the big one, because “read-only” was never true for data. Nothing stopped User.delete_all or a raw DELETE. Now your code runs inside a transaction that is always rolled back:

# This executes, returns a count, and then is undone:
User.where(inactive: true).delete_all
# => 42   (and the 42 rows are still there)

It is harm reduction, not a guarantee (DDL auto-commits on some adapters like MySQL, and after_commit callbacks don’t fire), but the common accident is now a no-op instead of data loss.

The timeout actually stops the code. The previous timeout wrapped the subprocess call and, when it fired, stopped waiting, while the runaway rails runner kept going, orphaned. The command now runs in its own process group, and a timeout kills the whole group. A runaway query or an infinite loop is terminated, not abandoned.

Dual-use constructs ask first. Some things are legitimately useful and also the classic sandbox-escape tools: send, public_send, const_get, Kernel#open. Rather than silently allowing them or hard-blocking them, the tool now stops and explains:

CONFIRMATION REQUIRED: This code uses constructs that can bypass
the sandbox's static safety checks:
  - `send`: dynamic dispatch can invoke methods the static scan
    cannot see, e.g. reaching blocked system/file APIs indirectly.

Ask the user to review the code and confirm. If they approve,
re-invoke execute_ruby with confirm_risky: true.

The model can’t wave this through on its own. A human reviews the code, and only then does the call run with confirm_risky: true. It puts a person in the loop exactly where the static analysis runs out of road.

Being honest about the boundary

I want to be direct about the limits, because a security tool that oversells itself is worse than one that doesn’t. These controls are layered defense, not hard isolation. The tool still executes real Ruby with full application access, so a determined bypass is possible; DDL and writes on non-default connections can escape the rollback; there are no per-process CPU or memory caps beyond the timeout.

If you need stronger guarantees, the right moves are outside the Ruby layer: run the server against a database user with read-only grants, and/or run it inside an OS-level sandbox (a container, sandbox-exec, seccomp). The SECURITY.md in the repository now documents both the controls and these limitations so you can make an informed call.

Manager-agnostic Ruby resolution

Separate from the sandbox, 1.6.0 fixes a long-standing annoyance for anyone whose Ruby isn’t the system default. Tools that shell out to bin/rails (execute_ruby, get_schema, and the introspection halves of analyze_models and analyze_controller_views) were exporting the rbenv-only RBENV_VERSION and running a login shell. On macOS, path_helper then reshuffled PATH so bin/rails booted under system Ruby and failed.

The runner now prepends the active version manager’s shims directory to PATH (mise, asdf, or rbenv, honoring MISE_DATA_DIR, XDG_DATA_HOME, ASDF_DATA_DIR, and RBENV_ROOT) and uses a non-login shell so the project’s Ruby survives. rvm, which has no shims, is sourced when present. The version comes from your project’s .ruby-version, .tool-versions, or .mise.toml, so different projects can use different Rubies with no extra configuration.

While I was in there, the analyzer path stopped swallowing errors with 2>/dev/null, so a Rails boot failure now surfaces the real message instead of a blank “Error executing Rails command.”

Namespaced models resolve properly

analyze_models could report a module-namespaced model as “not found” depending on how you referred to it. It now resolves from every input form: Namespace::Model, the path namespace/model, the flattened NamespaceModel, and the bare leaf Model. That holds independent of your app’s custom inflections. The introspection runner also derives the constant from the resolved file instead of interpolating raw input, which removes an injection surface in the generated scripts along the way.

Breaking change: Ruby 3.2 is dropped

The minimum supported Ruby is now 3.3 (required_ruby_version >= 3.3.0), and CI tests 3.3 and 3.4. The dependency updates in this release pull in transitive gems that require 3.3, so this was forced rather than chosen. If you’re on 3.2, upgrade your Ruby before updating the gem.

Security and dependencies

The dependency bump is also a security update. Upgrading to Puma 8.0.2 clears CVE-2026-47736 and CVE-2026-47737 (both HIGH, covering PROXY Protocol v1 remote memory exhaustion and repeated-header handling), and the lockfile refresh clears a concurrent-ruby advisory. bundler-audit reports clean.

Upgrading

gem update rails-mcp-server

Make sure you’re on Ruby 3.3 or newer first. If you use Claude Desktop, restart it to pick up the new version; the binary path in your configuration doesn’t change.

For new installations:

gem install rails-mcp-server
rails-mcp-config

What’s Next

Giving a model a Ruby runtime inside your app is a sharp tool. This release makes the edges more honest: fewer accidental cuts, and a clear label on what the guard does and doesn’t cover. The durable next step is real isolation: read-only database roles and OS-level sandboxing as first-class, documented setups rather than footnotes.

If you find a gap, the issue tracker is open, and security reports have a private channel through the repository’s Security tab. Pull requests are welcome.

--- URL: https://maquina.app/blog/2026/07/maquina-components-0-6-0-themeable-beyond-color/ Title: Maquina Components 0.6.0: Themeable Beyond Color ---

Maquina Components, the server-rendered UI component library for Rails and Tailwind, is out in 0.6.0. It adds a token layer for radius, elevation, focus rings and font weight, moves all engine CSS into @layer components, and fixes a focus ring that had been dead on six of seven button variants. The release is deliberately breaking: seven changes, and the first one fails silently in every existing application.

Until now the library was themeable in color and in nothing else. Radius, elevation, focus rings, font weight and hover states were written directly into the stylesheets, so changing any of them meant overriding selectors rather than declaring values. Theming one real application against the 0.5 releases took roughly 1,700 lines of override CSS, and most of that was not expressing a design. It was reaching past the cascade.

Why tokens alone would not have fixed it

Every engine rule was unlayered and carried specificity from its variant and state qualifiers, so appearance and structure shared one flat cascade. A theme could not reach the appearance without also being able to break the structure, and the engine defended against that by being hard to override at all. Adding tokens on top of that arrangement would have changed nothing.

Three things changed together. There is now a token layer: --control-radius, --surface-radius, --focus-ring-width, --elevation-raised, --label-weight and the rest, declared in @theme and read from every rule that used to hardcode a value. All twenty stylesheets moved into @layer components, which is what lets a caller’s Tailwind utilities apply. And specificity is flat now: every rule sits at 0,1,0, with variants and states in :where(), so a theme’s [data-component="button"] means every button, which it previously did not.

Which gives a contract worth stating plainly: a theme changes values, not selectors. Reach for a selector only when you want to change a component’s shape, like a different padding rhythm or a variant the engine does not ship.

A flat theme is six lines:

:root {
  --elevation-control: none;
  --elevation-raised: none;
  --elevation-overlay: none;
  --control-radius: 0.25rem;
  --surface-radius: 0.25rem;
}

The theming guide has the full token reference. Every component demo on the documentation site now carries a shape toggle in its chrome; flip it to brutal or soft and the whole library changes shape from token declarations alone.

The focus ring was dead

[data-component="button"]:focus-visible was declared before the variant rules, at the same specificity. Each variant then re-declared box-shadow for its own elevation, and later-at-equal-specificity wins. The focus ring was silently overwritten on every variant that set a shadow.

On the demo page, two of the sixteen buttons showed a ring, and both were destructive — the only variant that happened to re-declare its own focus rule after its variant rule. Primary, secondary, outline, ghost and link had no visible keyboard focus at all. That is a WCAG 2.4.7 failure. It shipped, and it was invisible in code review because every rule involved looked correct on its own.

Focus is now an outline rather than a box-shadow. Outlines do not participate in box-shadow, so a variant’s elevation can no longer overwrite a ring. They survive forced-colors mode, and they are not clipped by overflow: hidden ancestors, which had been quietly cutting rings off inside the sidebar and drawer. Every focusable button rings now, and a test asserts that state rules follow variant rules in every stylesheet, so the ordering that caused this cannot come back.

Your utility classes now win

css_classes: is the documented way to adjust one instance of a component, and it has been partly a lie. Because engine rules were unlayered, they beat any Tailwind utility passed through them:

These are layout failures, not restyles, and they failed quietly, which is why the workaround was always a wrapper element. With the engine in @layer components, utilities win. Measured: an input with a width utility goes from 448px to 137px.

Worth searching your views for css_classes: after upgrading. Anything you passed as decoration and never saw is about to take effect.

Before you upgrade: run the scanner

The release ships a scanner. Run it inside your application:

bin/rails maquina:doctor

It reads your CSS, views and JavaScript and prints file:line for every pattern this release changes, grouped by severity: the unlayered * rule, component overrides the token layer makes redundant, restated SVG data URIs, [data-active] presence selectors, .dark twins. It never edits anything and always exits 0.

Breaking changes

Seven. The first affects every existing application and fails silently.

  1. The preflight shim in your theme.css now outranks the engine. Your installed theme.css carries this rule:

    * {
      border-color: var(--color-border);
    }
    

    Unlayered CSS outranks every layer at any specificity. Now that engine rules live in @layer components, that one universal rule wins over the tinted borders on all alert and toast variants: a destructive alert renders with a plain grey border where 0.5.1 painted a red one. The generator template is fixed, but the rule lives in your file. Wrap it:

    @layer base {
      * {
        border-color: var(--color-border);
      }
    }
    
  2. Utility classes now win. Anything passed through css_classes: that was previously overridden by an engine rule will take effect.
  3. Radius and elevation defaults normalize. Card goes 12px to 8px, popovers 6px to 8px, and four shadow-lg sites collapse to --elevation-overlay.
  4. Focus rings become outlines, and form fields stop ringing on mouse click.
  5. merge_component_data precedence narrows to identity keys.
  6. Surfaces that sit above the page stop painting --background.
  7. Tinted badges lose a stray hairline the shim had been forcing onto them.

Every one of them is a value, so the upgrading guide closes with an appendix that restores the 0.5.1 look with a single token block.

Upgrading

bundle update maquina-components

Then re-run the installer to append the new shape and state tokens to your theme. It is idempotent and will not touch your palette:

bin/rails generate maquina_components:install

Then read the upgrading guide.

Also in this release

New:

Fixed:

What this reinforced

Every bug in this release looked correct in the stylesheet. The focus ring rule was right there in the file, and the dead table variant was right there in the partial. What caught them was asserting on compiled output and computed styles: does this token reach the browser, does this rule come after that one, does this element actually have a ring. The tests that came out of the audit assert those things.

The quieter finding was a set of styled hooks that turned out to be emitted by nothing at all — CSS that read as supported API and matched no markup. That is worse than a missing feature, because it looks finished. Six became real partials here and two were deleted.

Documentation

Source

--- URL: https://maquina.app/blog/2026/07/introducing-nexo/ Title: Introducing Nexo: the harness for Ruby agents ---

Ruby has most of the pieces you need to build an AI agent already, and they’re good. ruby_llm gives you a provider-neutral chat loop and tool calling that’s cleaner than most language SDKs out there, one API across a dozen providers instead of a new SDK per vendor. ruby_llm-skills gives you SKILL.md loading. ruby_llm-mcp gives you MCP servers. ruby_llm-schema gives you structured output. What none of them give you is a front door, one place where those pieces snap together with defaults you don’t have to think about twice.

Building an agent on top of these gems means wiring the same sandbox and permissions boilerplate into every new project, by hand, every time. Nexo exists to stop that. It doesn’t rebuild the tool-call loop, skill loading, MCP, or structured output; those already exist, they’re well built, and duplicating them would just make Nexo worse at the things ruby_llm and its ecosystem already do well. Nexo sits on top of them instead. Two things were missing from the ecosystem, and those are the two things Nexo adds: a sandbox and permissions seam, and a real job primitive called WorkflowRun.

Agent = Model + Harness

A model on its own forgets everything the moment it finishes a response. Turning “a model that replies” into “an agent that does work” takes tools, a place for those tools to act, a policy for what they’re allowed to do, and a way to track a job from start to finish. That collection of things is the harness. Ruby has all of it, but scattered across gems with different DSLs, and wiring them by hand is the first thing every new agent project reinvents.

Nexo is the nexus: the connective tissue between a model and everything else an agent needs, and between the fragmented RubyLLM-ecosystem gems and one coherent whole.

require "nexo"

class CodeReviewer < Nexo::Agent
  model       ENV.fetch("NEXO_MODEL")   # any ruby_llm model, no vendor default
  sandbox     :local
  permissions :read_only

  instructions "You are a careful code reviewer. Read files and report issues. Do not write files."
end

CodeReviewer.new(cwd: "/path/to/repo").prompt("Review the auth module")

What Nexo adds: a sandbox and permissions seam

An agent’s tools have to act somewhere, and something has to decide what they’re allowed to do there. Nexo splits that into two questions, where (the sandbox) and what (the permission mode), and answers both safely by default: :virtual sandbox, :read_only permissions. An untrusted model gets zero host access until you opt into more, explicitly, in your own code.

A write under :read_only, a shell call on Virtual, any denied action comes back as { error: ... } and the loop keeps running. The model can read that and adjust; a permission denial isn’t a reason to crash the process. A path that tries to escape its sandbox is a different kind of failure, and it raises SecurityError instead.

What Nexo adds: WorkflowRun

An agent accumulates context, it’s a conversation that keeps going. A lot of real work isn’t that. It’s a finite task that starts, does something, and finishes with a result you can check on later, sometimes from a different process entirely. Nothing in the ecosystem covered that cleanly, so Nexo adds Workflow.

A workflow can drive an agent too, so the two primitives Nexo owns fold into one recipe: stage inputs into the run’s sandbox, run the agent against them, capture the output. The agent class macro names the agent, and run_agent runs it bound to the run’s own sandbox:

class Summarizer < Nexo::Agent
  model ENV.fetch("NEXO_MODEL")   # any ruby_llm model, no vendor default

  instructions "Summarize the given text in two plain sentences. No commentary."
end

class SummarizeDocument < Nexo::Workflow
  agent Summarizer

  def call(payload)
    emit(:started, doc_id: payload[:doc_id])
    response = run_agent("Summarize this document:\n\n#{payload[:text]}")
    emit(:summarized, length: response.content.length)
    { summary: response.content }
  end
end

run = SummarizeDocument.run(doc_id: 123, text: "Long text...")
run.id      # => "0191d6b2-..."  (UUID v7, time-ordered)
run.status  # => "done"
run.result  # => { "summary" => "The document covers ..." }

Every run gets a stable id, a status, a payload, a result, and an ordered event log you can replay with Nexo::Workflow.logs(run.id) or rake nexo:logs[id]. The agent’s tool calls and its final response land in that same log alongside the workflow’s own :started/:summarized events, so a driven run reads as one story instead of two logs stitched together by hand. Outside Rails it all records to memory and just works offline, no database required; inside Rails, install the migration and the same code persists to a nexo_workflow_runs table instead, with no other change.

A workflow that raises is recorded as failed and the exception still propagates to your caller, the opposite of a tool failure, which returns { error: ... } and never interrupts the agent loop. Jobs that need to pause and pick back up later, waiting on a human approval or a slow external process, get the same lifecycle extended with checkpoints, suspend!, and resume, covered in the durable workflows doc.

Provider-neutral, on purpose

The only hard dependency is ruby_llm. No hardcoded default model, no vendor SDK called directly anywhere in the core loop; every example in the docs pulls the model from ENV.fetch("NEXO_MODEL"), just as happy pointing at a local Ollama model as a hosted one. ruby_llm-skills, ruby_llm-mcp, and an optional Anthropic-oriented autonomous loop are all soft dependencies, required lazily, and they raise a clear Nexo::MissingDependencyError with install instructions if you haven’t added them. You only pay for what you use.

Where it stands today

Nexo is early and the API isn’t stable yet. The Apple container runtime parity table in the docs says so plainly: Docker is the verified path today, and Apple’s CLI flags are encoded from the reference mapping but not yet confirmed against a live daemon. The caveat is published rather than papered over.

What’s shipped: the sandbox and permissions seam across Virtual, Local, Container, and Remote; the Workflow/WorkflowRun lifecycle with staging, artifacts, and durable checkpoints; skills, MCP behind a fail-closed gate, a web fetch tool with an SSRF guard, sessions, and a Rails engine with generators for the conventional app/agents / app/workflows / app/skills layout.

gem "nexo_ai"

The gem publishes as nexo_ai; everything in the code lives under Nexo::.

None of this exists without the RubyLLM ecosystem underneath it. Nexo was worth building because that foundation, ruby_llm and everything built around it, is solid.

The Nexo documentation has the full index, sandboxes, permissions, tools, loop backends, workflows, durable workflows, skills, MCP, web, sessions, Rails, and concurrency. If you’re already building on ruby_llm and you’ve written this same sandbox-and-permissions setup more than once, this is for you.

Repo: github.com/maquina-app/nexo

--- URL: https://maquina.app/blog/2026/07/maquina-components-0-5-0-drawer-and-scaffold-templates/ Title: Maquina Components 0.5.0: Drawer and Scaffold Templates ---

This release adds a Turbo-aware Drawer component, a scaffold_templates generator that makes rails generate scaffold produce styled views, and engine helpers included in the generated helper module. It’s also a first for the project: most of it came from contributors.

Drawer

A slide-out panel with an overlay backdrop, built from sub-partials the same way Card and Sidebar are. A provider owns the state; header, content, and footer structure the panel; a trigger toggles it and a close dismisses it.

<%= render "components/drawer/provider", default_open: drawer_open? do %>
  <%= render "components/drawer", state: drawer_state do %>
    <%= render "components/drawer/header" do %>
      <h2 class="text-lg font-semibold">Filters</h2>
      <%= render "components/drawer/close" %>
    <% end %>

    <%= render "components/drawer/content" do %>
      <!-- Panel body -->
    <% end %>

    <%= render "components/drawer/footer" do %>
      <!-- Apply / Reset -->
    <% end %>
  <% end %>
<% end %>

Drop a trigger anywhere on the page as a plain toggle button:

<%= render "components/drawer/trigger" %>

The drawer opens from the right by default; pass side: :left to open from the other edge.

Features: compound structure (provider, header, content, footer, trigger, close), left or right side with an overlay backdrop, a configurable Cmd/Ctrl+D shortcut, cookie-based state persistence, and full Turbo Drive and Morph compatibility.

Surviving Turbo

The reason a drawer is more than a CSS transition is the lifecycle around it. The controller handles the three places Turbo usually breaks a stateful component:

Cache teardown. Before Turbo snapshots the page for its cache, the drawer closes and hides its backdrop. A restored snapshot never comes back frozen mid-transition.

Morph awareness. With turbo_refresh_method_tag :morph, the server re-renders the page in its default state—it doesn’t know the drawer was open. The controller re-reads its cookie on morph and reasserts the correct state—the same fix the sidebar got in 0.4.0.

Persistence. State lives in a cookie, so the drawer holds across full page loads and Turbo navigations alike.

That state is exposed through three helpers, so your server-rendered markup and the client agree on the first paint:

drawer_state    # => :open or :closed
drawer_open?    # => true / false
drawer_closed?  # => true / false

Passing default_open: drawer_open? into the provider and state: drawer_state into the drawer, as in the usage above, is what closes the loop between the cookie and the initial render.

Scaffold Templates

Having a component library is one thing; getting your generated code to use it is another. The new scaffold_templates generator closes that gap.

bin/rails generate maquina_components:scaffold_templates

It copies a set of ERB scaffold templates—index, show, new, edit, _form, and the record partial—into lib/templates/erb/scaffold/. Rails has always let you override its generator templates from that path; what was missing was a set that renders with the component library. Now they ship with the gem.

From then on, the standard scaffold generator produces styled views out of the box:

bin/rails generate scaffold Post title:string body:text

You get tables, buttons, and form fields built with Maquina Components instead of Rails’ default markup—before writing any view code. Because the templates are copied into your app, they’re yours to edit afterward.

Engine Helpers in the Generated Module

A quieter change in the same direction. The generated MaquinaComponentsHelper now includes IconsHelper, SidebarHelper, and ToastHelper. Helpers like icon_for, sidebar_open?, and toast_flash_messages are available in host-app views without an extra include in ApplicationHelper. It’s a one-line diff in the template that removes a papercut every new install used to hit—reaching for icon_for and getting a NoMethodError because the module wasn’t wired up yet.

Icon Class Handling

The one bug fix this release tightens apply_icon_options. It now guards against nil and non-string class values, HTML-escapes the class before it reaches the markup, and injects a class attribute onto <svg> elements that didn’t already have one. Small, but exactly the kind of edge case that only surfaces once icons are being passed around inside real templates.

Contributors

This release was built mostly by two people who aren’t me:

Thank you both.

Upgrading

bundle update maquina_components

Then, when you want styled scaffolds, install the templates:

bin/rails generate maquina_components:scaffold_templates

There are no breaking changes in this release.

What This Reinforced

Three of the four changes pull the same way: they make the gem lean on Rails instead of sitting next to it. The scaffold templates go through Rails’ own generator override path. Helpers are included the way any Rails helper is. And the Drawer keeps its state in a cookie and reacts to Turbo’s morph, the way the sidebar already does. Less to learn, fewer seams to trip over.

The other thing worth saying is that I reviewed this release more than I wrote it. Gregorio and Juan built the components; I merged them. That says more about where the project is than any one feature does.

Documentation

Source

--- URL: https://maquina.app/blog/2026/07/introducing-fragua/ Title: Introducing Fragua: the harness already composed for Rails ---

Fragua is live in private beta. It’s an AI agent orchestrator built specifically for Rails developers, and it’s the newest product under the Maquina umbrella.

Fragua is Spanish for forge — the place where raw material becomes a finished tool under heat and pressure. That’s the intent: take a project from a one-line idea to a shipped pull request, running through research, planning, spec-driven development, and execution.

Why we built it

Coding agents are good at writing code and bad at remembering why. Every new session tends to start from zero — the spec gets re-pasted, the data model gets re-explained, and the agent has no memory of a decision it helped make yesterday.

What Fragua actually sells isn’t an AI that writes code. It’s durable context and a disciplined workflow. Each phase — brief, research, plan, spec — produces a structured artifact that persists in the workspace and becomes context for the next agent. By the time the execution agent runs, it’s not guessing; it’s reading the plan, the technical guide, and the spec that already exist.

The shape of it

Foundation → Spec → Execution → Pull Request

Only Foundation (a working copy of your codebase) and an accepted Spec are required. Everything else — Product Brief, Research, MVP Plan, Brand Guide, Technical Guide — is optional enrichment that makes the execution agent sharper without ever gating it. Execution itself starts on its own: a sweep every ~2 minutes picks up accepted specs and approved issue fixes, so there’s no button for “start the build.”

Under the hood, Fragua splits the work across two planes — a web app that plans and directs, and your own machine that actually runs the agent and pushes the branch. We walk through that split, plus a live tour of the web app, in the video below.

Rails-first, not Rails-only

Every new app Fragua scaffolds is Rails 8.1 with Hotwire — Stimulus controllers, Minitest fixtures, Turbo Streams, the full set of conventions the agents already know how to read and write. That part is deliberate and non-negotiable: it’s what lets Fragua write code that looks like it belongs in your app instead of generic output.

Where you’re pointing Fragua at a codebase that already exists, the door is wider. Foundation and the Spec and Issue agents read your repo rather than dictate its stack, so brownfield projects outside Rails are something we’re genuinely open to exploring — if that’s your situation, say so in your access request and tell us what you’re working in.

What’s actually different day to day

Watch the two-plane split in action


Where things stand

Fragua is in private beta, by invitation. A human reads every request before a seat opens — there’s no schedule, seats open based on fit rather than a calendar.

Plan Price Includes
Solo $19/month 1 user, unlimited workspaces, full cost dashboard, per-feature worktrees, BYOK
Team $69/month, flat 2–10 users, shared workspaces, admin & member roles, audit trail across every run, priority support, BYOK
Enterprise Contact for quote 11+ users, unlimited workspaces, on-premises deployment, dedicated support, BYOK

All plans are free for the duration of the beta. Pricing begins 30 days after public launch, with email notice ahead of time. A read-only viewer role is on the roadmap — not shipped yet.

Need more than 10 seats, or want it on-premises? Email mario@fragua.app and we’ll work out the details directly.

If you’re building on Rails 8.x and Hotwire — solo, freelance, a small consultancy, or a growing product team — request access at fragua.app/#access. Working in something else on an existing codebase? Tell us in the request; we’d like to hear about it.

The full docs live at fragua.app, including dedicated pages on observability, teams, and trust.

Agents that respect the craft. Your host, your keys, your repo.


Fragua is built by Maquina.

--- URL: https://maquina.app/blog/2026/06/recuerd0-mcp-and-redesign/ Title: recuerd0 Now Has an MCP Server — and a Better Look ---

recuerd0 has always had multiple ways to access your knowledge: a REST API, a CLI for terminal workflows, and an agents guide for hooking coding agents directly into your memories. The access model has been there from the start — Bearer token, call the endpoints, get your context back.

MCP adds another path. Instead of configuring the connection upfront, any MCP client can reach your memories on demand — mid-conversation, mid-task, whenever the context is needed — without a token to copy or a config file to edit. Building that properly meant adding an OAuth 2.1 authorization server, a consent screen, and connected application management — surface area that needed to feel right in the browser.

While that work was underway, the editing experience got the same attention. The textarea that had always been good enough stopped feeling good enough when you’re managing a knowledge base you actually rely on. The fix was already sitting in plain sight: 37signals shipped House MD with Writebook, and it fit recuerd0’s requirements almost exactly.

Both changes landed at the same time, alongside a UI pass that tightened the information hierarchy across the workspace and memory views.


MCP support

recuerd0 runs a remote MCP server at POST /mcp — Streamable HTTP, JSON-RPC 2.0, compatible with any client that speaks the protocol.

The connection is consent-based. The client registers itself with Dynamic Client Registration, you approve a short permission screen in the browser, and that’s it. No token to generate, no config to edit. Approve once per client; disconnect any time from your profile.

recuerd0 OAuth consent screen approving an MCP client connection, with connected application management in the browser

The authorization server is built directly into the Rails app — no external OAuth service, no added dependencies. PKCE instead of shared secrets. Access tokens last one hour; refresh tokens rotate silently, so a connected client stays connected without interrupting you.

Six tools

The server exposes six tools across three read and three write operations, all scoped to your account’s workspaces:

Tool Type What it does
list_workspaces read List the workspaces in your account
list_memories read List memories; optional FTS5 query and category filter
read_memory read Read the full content of one memory
create_memory write Create a memory, with optional tags
update_memory write Update title, content, category, or tags
create_version write Append an immutable version, preserving history

Read tools require the memories:read scope. Write tools — including create_version — require memories:write. Scopes map to the existing read_only / full_access permission tiers, so there’s no parallel auth system to reason about.

create_version is the one worth pausing on. Every memory in recuerd0 already has a versioning model — you can snapshot a decision or convention as it evolves and keep the full history. The MCP tool exposes that directly. If a client updates a memory and you want to preserve what was there before, it can append a version rather than overwrite.

Connect it

The MCP endpoint is at your instance URL:

https://recuerd0.ai/mcp

For self-hosted instances, replace the domain with your own. Any MCP client that supports custom connectors — Claude Desktop, Claude.ai, Cursor, and others — can connect by pointing at that URL. The OAuth handshake runs itself.

Full documentation is at recuerd0.ai/mcp.


The editor

recuerd0 stores knowledge as markdown. The editing experience should match that — not fight it. A plain textarea works, but it asks you to hold the syntax in your head while you write. After using it long enough, the friction starts to matter.

recuerd0's new markdown editor based on House MD, with a minimal toolbar for bold, italic, quote, code, link, and lists

The new editor is based on House MD — the markdown editor 37signals built for Writebook. Minimal toolbar: bold, italic, quote, code, link, bullet list, numbered list. It writes markdown, not HTML. No mode switching, no preview tab — what you see while editing stays close to what the rendered output looks like.

One thing worth being explicit about: everything that reads your memories — the API, the CLI, MCP tools — still gets raw markdown. The editor doesn’t change that contract.


The UI

recuerd0 is built on Maquina Components — a Rails component library with cards, badges, buttons, dialogs, and form elements. The component foundation was already there; this pass refined how those pieces are composed and extended them with patterns specific to what recuerd0 actually is.

Redesigned recuerd0 workspace view with a deliberate information hierarchy, a labeled pinned section, and list/grid view preferences

The workspace and memory views got a more deliberate information hierarchy — metadata where you need it, actions reachable on touch and keyboard, pinned items in their own labeled section rather than blended into the list. The color system was tightened so the app reads consistently across every page, and view preferences (list vs grid, cards vs compact) now persist between sessions.

The workspace index and workspace show pages have been updated. Memory show, the editor page, and settings are next.


The MCP server is live. Full documentation at recuerd0.ai/mcp.

recuerd0 is free to self-host. Managed hosting is $15/month.

--- URL: https://maquina.app/blog/2026/04/recuerd0-api-release/ Title: Recuerd0 Now Reads Like a Filesystem ---

The new Recuerd0 API release teaches the memory store to behave like a filesystem — so AI agents already fluent in grep, glob, and read need no new vocabulary.


Recuerd0 is the persistent memory store for AI coding agents built by Maquina, and this release reshapes how agents read from it. When an AI agent reaches into Recuerd0 for context, the bottleneck has never been storage. It has been how much the agent has to pull back to get to the one fact it needs. A 2,000-line transcript should not have to fit within the agent’s context window to answer “did we decide to use Postgres?” The new release fixes that — and a handful of other long-standing rough edges — by giving the API the same primitives every coding agent already knows: glob, grep, and ranged read.

Here is what is new.

File-tool API: glob, grep, and ranged read on memories

The biggest shift in this release is conceptual. Memories are no longer monolithic blobs that you fetch whole. They are addressable like files.

Glob. The browse and list endpoints accept a title glob pattern. * matches any sequence of characters, ? matches a single character. Combined with tags, source, category, and workspace_id, the agent can narrow a thousand memories down to the dozen worth looking at without reading any bodies.

GET /memories.json?title=Meeting*&tags=design,api&category=decision

Ranged read. GET /workspaces/:id/memories/:id.json now accepts line_start and line_end (1-based, inclusive). The response always echoes total_lines, so the client knows how much memory is available and can compute a tail window in a single follow-up call.

GET /workspaces/1/memories/42.json?line_start=40&line_end=55

There is no head= or tail= parameter — and that is deliberate. line_start=1&line_end=20 is “head 20”; line_start=(total_lines - 19)&line_end=total_lines is “tail 20”. One verb covers both, and the client never has to learn a parallel vocabulary for the same operation.

Grep with line numbers. ?mode=grep&q=<query> switches the same endpoint into a grep response. Instead of returning the body, it returns an array of matches:

{
  "content": {
    "total_lines": 2174,
    "matches": [
      {
        "line_number": 1247,
        "line": "Decided: Postgres for the analytics warehouse, SQLite for everything else.",
        "context_before": ["## Database choice"],
        "context_after": ["Reason: ops simplicity outweighs the JOIN ceiling for our scale."]
      }
    ]
  }
}

Optional context, before, and after parameters control how many surrounding lines to return — capped at 10 each, like grep -C, -B, and -A. The full-text search endpoint (/search.json) supports the same grep mode for cross-memory queries.

The two-step recipe the agent should reach for: first, use grep to locate the line numbers; then issue a follow-up line_start/line_end call to fetch only the surrounding window. A 2,000-line memory becomes a 20-line answer.

Memory categories

Every memory now carries a category: decision, discovery, preference, or general (the default). It is a small thing, but it changes how an agent reasons about what it is reading. A decision is load-bearing — something the team chose and is sticking with. A discovery is a fact about the world. A preference is taste. The agent does not have to infer the difference from prose; it is right there in the metadata, filterable from any list endpoint.

GET /memories.json?category=decision&sort=updated_at

Memories can now reference each other across workspaces with first-class “see also” links. The Rails decision in your Backend workspace can point to the deployment write-up in Infrastructure without copying anything. Each memory’s response includes a links_count so the agent knows there is more context one hop away, and dedicated endpoints under /memories/:id/links let it list and traverse them.

This is the connective tissue for context that lives in more than one place — which, in practice, is most context worth keeping.

Workspace wake-up endpoint

A new endpoint, GET /workspaces/:id/context.json, returns a compact “wake-up” payload for an agent starting a fresh session: workspace metadata, recent memory titles, and the highlights an agent should know about before it does anything else. It is the answer to “you are picking up where you left off, here is the room you just walked into.”

Pair it with a Claude Code session-start hook and a new conversation begins with the right context already loaded — no manual recuerd0 memory list dance, no asking the user to repeat themselves.

HTTP caching across the API

All read endpoints now emit ETag and Last-Modified headers and respect conditional requests. A client that sends If-None-Match for a memory it already has receives a 304 Not Modified response with an empty body. For agents that re-fetch the same workspace several times in a session, this is a meaningful drop in tokens shipped over the wire — and a meaningful drop in load on the database.

Grep and ranged-read responses are correctly bypassed by the cache, since they are derived from query parameters that change with each call.

CLI: recuerd0 memory read

The recuerd0-cli gains a memory read command group that wraps the new endpoints so a human (or a terminal-bound agent) can use them without hand-crafting URLs:

recuerd0 memory read head 42 --lines 20
recuerd0 memory read tail 42 --lines 20
recuerd0 memory read lines 42 --start 100 --end 140
recuerd0 memory read grep 42 "Postgres" --context 2 --pretty

In --pretty mode, the grep subcommand emits a breadcrumb for each hit, suggesting the exact memory read lines, followed by a call to fetch a window around it. The two-step pattern is right there in the output — no thinking required.

Agent guidance baked in

The Claude Code recuerd0 agent skill now ships guidance for when to use the new primitives, not just how. The dedup-before-write protocol prefers memory read grep over memory show for large candidates. The workflow guidelines tell the agent: when total_lines > ~200, grep first and fetch a window — reserve full reads for memories you genuinely need in their entirety.

The point of teaching these patterns to the agent is the same as the point of adding them to the API in the first place: make the cheap thing the obvious thing.

Documentation

Every endpoint above is documented in the public API reference, and the CLI reference on recuerd0.ai has been updated to match. The grep→fetch-window workflow is called out as a recipe in both places, with worked examples.

Why this release matters

Coding agents are getting fluent. They already know how to use glob, grep, and read — those primitives are how they navigate filesystems every day. Recuerd0’s job is not to invent a new vocabulary for context retrieval; it is to look enough like a filesystem that agents do not have to learn one.

This release is that bet, made concrete. A memory is now something you can grep. A workspace is now something you can wake up in. A long transcript no longer has to fit entirely within a context window just so the agent can quote one line from it.

Get the update

Frequently asked questions

How do I grep a Recuerd0 memory? Send GET /workspaces/:id/memories/:id.json?mode=grep&q=<query>. The response returns line numbers and surrounding context instead of the full body. From the CLI: recuerd0 memory read grep <id> "<query>" --context 2.

What is the difference between ranged read and grep mode? Grep mode finds where a string appears (returns matching line numbers with context). Ranged read fetches what is at known line numbers via line_start and line_end. The recommended workflow is grep first to locate, then ranged read to fetch a window.

Do I have to update the CLI and Claude Code plugin? Yes. The new memory read commands ship in the latest recuerd0-cli, and the grep-first agent guidance ships in the updated recuerd0 plugin in the Claude Code marketplace. Older versions will keep working but won’t expose the new endpoints.

What are memory categories used for? Each memory is tagged as decision, discovery, preference, or general. Agents (and humans) can filter by category to find load-bearing decisions without sifting through general notes.

Does HTTP caching apply to grep queries? No. ETag/Last-Modified caching applies to whole-memory and list reads. Grep and ranged-read responses are derived from query parameters and bypass the cache by design.


Recuerd0 is built by Maquina. Source available under OSASSY license.

--- URL: https://maquina.app/blog/2026/03/mvp-creator-from-idea-to-documents/ Title: MVP Creator: From Idea to Documents in Three Prompts ---

Before writing a single line of code, I need to understand what I’m building. Not abstractly — concretely: who the users are, what the real problem is, what the app is called, what voice it has, what technical decisions I’m making from the start. For a long time, that work happened informally — in scattered notes, in my head, or spread across different roles on a team: product knowledge in one conversation, brand direction in another, architecture in some document nobody kept updated. Now I formalize it with an agent called MVP Creator.

In this video — the first in a series about my personal process with AI — I show how I use MVP Creator to generate the complete set of foundation documents for a new project: research report, business plan, brand guide, and technical guide. All of it with three prompts, from an initial idea to documentation ready to hand off to Claude Code.

The Three Prompts

The example in the video is a photo delivery platform for professional photographers. These are the exact prompts I use, in order.


Prompt 1 — The Idea and Context

Help me create an MVP for a photo delivery platform for professional photographers.
Think of it as a private gallery where photographers deliver finished work to clients.

The core concept: a photographer creates a Project (for a client or personal work),
organizes photos into Collections within that project, and shares the gallery via
single-use expirable links. Invited clients can view, comment, like, and download
photos in their preferred quality.

Key features:
- Projects with collections and high-resolution photo uploads
- Active Storage for thumbnail + quality variants (low/medium/high)
- Reorderable photos within collections, cover photo per collection
- Shareable links: single-use, expire in 7 days, create read-only sessions
- Download: single photo or multi-select as zip, with quality choice

Target users: freelance and studio photographers in Latin America
Language: Spanish-first, English secondary
App name: I'm thinking "Liminal" — open to suggestions

Research these competitors: https://www.pic-time.com and
https://www.picdrop.com/web — also look at how Google Drive handles
shared folder UX as a reference point.

Use the MVP Creator skill to generate the full documentation set.

With this prompt the agent launches competitor research, runs through the discovery questions, and generates the four foundation documents: research report, business plan, brand guide, and technical guide.


Prompt 2 — Brand Voice

Based on everything we've defined about Liminal — the LATAM market, photographers
delivering work to clients, the quiet confidence of the name itself — write a brand
voice document.

The voice should feel like a photographer who has found their style and doesn't need
to announce it. Not austere, but economical. Someone who chooses words the way they
choose light — deliberately, with care for what gets left out as much as what stays in.

Professionalism here means craft, not corporate. The app handles something personal
— a photographer's finished work, a client's important memories. The voice should
honor that weight without becoming precious about it.

Influences: the way Magnum Photos writes about their work. The directness of a good
photo caption. Not the breathless enthusiasm of a SaaS landing page.

The document should include:
- Core personality traits (3–4, with explanation)
- Tone spectrum (when to be warmer vs. more spare)
- Vocabulary: words we use, words we avoid
- UI microcopy examples (button labels, empty states, error messages)
- Both Spanish and English examples side by side

This second prompt goes straight to the character of the app. A brand voice guide is a document that rarely gets produced in an MVP phase — and it’s one of the most useful when the time comes to write microcopy or define how the app speaks to its users.


Prompt 3 — UI Mocks

Using the frontend-design skill, create UI mocks for Liminal's critical screens.
Pull from the brand guide already established and the brand voice: quiet craft,
deliberate, editorial — not SaaS.

Prioritize these screens in order:

Client-facing (unauthenticated, via share link):
1. Gallery landing — the first thing a client sees when they open their link.
2. Collection view — browsing photos within a collection, with like, comment,
   and download interactions visible.
3. Download selection — choosing photos and quality before downloading as zip.

Photographer-facing (authenticated):
4. Project dashboard — list of projects with status at a glance.
5. Collection editor — uploading photos, reordering, setting cover photo.
6. Share link manager — creating and tracking links, seeing which have been used.

For each screen:
- Design for desktop first, note mobile considerations
- Show real placeholder content — no Lorem Ipsum
- Embed a short design rationale note explaining the key decision made for that screen

Aesthetic direction: editorial photography magazine meets quiet utility. The UI
should feel like it was designed by someone who photographs, not someone who ships
dashboards.

The third prompt uses the frontend-design skill together with Maquina Components to generate HTML mocks of the critical screens. The result isn’t a Figma file — it’s a functional visual reference, coherent with the brand guide, before opening the editor.


The Result

Three prompts. Six documents. Mocks of the main screens. All the context needed to hand off to Claude Code and start generating code with direction.

It’s the same process I used to build Resto, a personal finance app based on the Japanese Kakeibo method.

The video runs 40 minutes. It’s not an accelerated demo — it’s the real process, iterations and corrections included.


Installation

All plugins are available in the maquina-app/rails-claude-code repository. Full documentation at MVP Creator — Documentation.

To install MVP Creator in Claude Code:

# Add the marketplace
/plugin marketplace add maquina-app/rails-claude-code

# Install the plugin
/plugin install mvp-creator@maquina

To install the full set of plugins used in this series:

/plugin install rails-simplifier@maquina
/plugin install rails-upgrade-assistant@maquina
/plugin install maquina-ui-standards@maquina
/plugin install mvp-creator@maquina
/plugin install better-stimulus@maquina
/plugin install spec-driven-development@maquina

For the Claude graphical interface, download the repository as a zip, extract the mvp-creator folder, rename the extension to .skill, and drag it into the Claude window to install it.

--- URL: https://maquina.app/blog/2026/03/maquina-generators-production-ready-rails-setup/ Title: Maquina Generators: From rails new to Production-Ready ---

Every Rails project starts the same way. You run rails new, you get a clean app with sensible defaults, and then you do the setup work before you can write application code. Authentication with signup and multi-tenancy. Rate limiting. Background jobs with a dashboard. Error tracking. Mailer templates. Security headers. It’s repetitive, sure, but it’s the work that gets your app to the point where you can build the thing you actually sat down to build.

I’ve done this enough times to know exactly what’s coming. The order changes, the names of the models drift slightly, but the shape of the work is identical. It’s not that Rails is missing anything — it’s that the space between rails new and “ready to build features” is full of choices that are mostly already made. You just have to type them out each time.

Maquina Generators automate that setup. One command after rails new, and you have authentication, multi-tenancy, roles, job processing, error tracking, request protection, and ops dashboards. All generated into your app as plain Rails code. No runtime dependency.

What Maquina Generators Do

The gem lives in your development group. It generates standalone application code — models, controllers, views, migrations, initializers, mailers — and then you can delete the gem. Nothing it produces requires the gem at runtime. No engine mounts, no middleware injection, no monkey patches. Just files in your app that you own completely.

The workflow is five commands:

rails new myapp --css tailwind
bundle add maquina-generators --group development
rails generate maquina:app --auth clave
bin/rails db:migrate
bin/dev

That’s it. Auth with email verification codes, an Account model with roles, Rack Attack blocking scanners and throttling logins, Solid Queue with a Procfile, Solid Errors catching exceptions, Mission Control monitoring your jobs — all wired up, all running.

Generated app homepage showing authentication, background jobs, error tracking, rate limiting, caching, and real-time features

Seven generators handle the pieces:

Generator Purpose
App Full application setup — orchestrates everything below
Clave Passwordless email-code authentication
Registration Password-based auth with accounts and roles
Rack Attack Request protection and IP throttling
Solid Queue Background job processing with separate database
Solid Errors Error tracking dashboard
Mission Control Job queue monitoring dashboard

The App generator is the orchestrator. It runs whichever auth generator you choose, then all the infrastructure generators in sequence. You can also run each generator independently if you only need part of the stack.

The full documentation covers every generator, option, and generated file in detail.

Two Authentication Options

Rails 8’s built-in rails generate authentication gives you login. It doesn’t give you signup. It doesn’t give you accounts, roles, or multi-tenancy. For most applications, login alone isn’t enough.

Maquina Generators offer two complete authentication systems that pick up where Rails leaves off.

Clave: Passwordless

Clave implements passwordless authentication using email verification codes. The user enters their email, receives a 6-digit hexadecimal code, enters the code, and they’re in. No passwords to store, no password resets to build, no complexity requirements to argue about.

User enters email → receives 6-digit code → enters code → signed in

Codes expire in 15 minutes. There’s a 15-minute cooldown before a resend. Login attempts are rate-limited to 10 per 3 minutes. Sessions last 30 days by default. Plus characters are blocked in email addresses to prevent alias attacks.

Beyond sign-in, Clave generates a full multi-tenancy layer. Every user belongs to an Account. The first user who creates an account becomes its admin. A role enum — admin or member — handles authorization from there.

Current.user          # The signed-in user
Current.account       # The user's account
Current.user.admin?   # Check role

You scope queries through the account, and cross-tenant access is prevented at the model level:

@projects = Current.account.projects

Clave generates models, controllers, a mailer with HTML and text templates, a daily cleanup job for expired sessions and codes, a test helper with sign_in_as(user), and full i18n support in English and Spanish.

Registration: Password-Based

If you prefer passwords, the Registration generator builds on Rails 8’s authentication. It runs rails generate authentication first, then adds what’s missing: an Account model, belongs_to :account on User, the role enum, a RegistrationsController that creates an Account and User in a single transaction, and Tailwind-styled views.

class RegistrationsController < ApplicationController
  allow_unauthenticated_access
  rate_limit to: 10, within: 3.minutes, only: :create

  def create
    ActiveRecord::Base.transaction do
      account = Account.create!(name: params[:account_name])
      user = account.users.create!(
        name: params[:name],
        email_address: params[:email_address],
        password: params[:password],
        role: :admin
      )
    end
    start_new_session_for user
    redirect_to root_path
  end
end

Generated sign-in page with email and password fields

Same Current.user, Current.account, and role-based authorization as Clave. The multi-tenancy pattern is identical — only the sign-in mechanism differs.

The generators documentation covers every option, model, and controller for both auth systems.

The Ops Layer

Authentication is the most visible piece, but the App generator does more than auth. It sets up a complete operational layer that most Rails apps need but few have on day one.

Rack Attack gets configured with real-world defaults. PHP file requests, WordPress scanning paths, .env and .git probes — all blocked immediately. Sensitive paths like /cgi-bin, /phpmyadmin, and /actuator return 403. General traffic is throttled to 300 requests per 5 minutes per IP, with asset paths exempted. Login endpoints get tighter limits: 5 attempts per 20 seconds.

Solid Queue is set up as the Active Job backend with its own SQLite database, a Procfile entry for the worker process, and a recurring schedule that runs the authentication cleanup job daily at 3am. The configuration lives in config/solid_queue.yml — three worker threads, half-second polling, standard dispatching.

Solid Errors and Mission Control Jobs get mounted as dashboards with custom Tailwind views. Mission Control alone has 41 view files — job listings, queue status, worker monitoring, recurring task management — all styled to match your application instead of looking like a default engine mount.

Both dashboards share the same HTTP basic auth credentials:

# bin/rails credentials:edit
backstage:
  username: admin
  password: your_secure_password

One set of credentials, stored in Rails credentials. Environment variable fallbacks if you prefer. After running the generators, you have /admin/solid_errors and /admin/mission_control_jobs working from the first bin/dev.

Admin tools section showing Solid Errors and Mission Control Jobs dashboards after sign-in

The App generator also sets up multi-database configuration — separate SQLite databases for the queue, cache, cable, and errors — installs Active Storage and Action Text, configures Turbo morphing, adds brakeman and Standard for code quality, and creates a HomeController with a root route. It’s the full post-rails new checklist, automated.

Own the Code

This is the part that matters most. Maquina Generators is a development-only gem. It generates code into your application and then it’s done. You can — and should — delete it from your Gemfile once you’ve run the generators.

# Gemfile — remove after generating
group :development do
  gem "maquina-generators"
end

Every file it produces is a standard Rails file in a standard location. Models in app/models, controllers in app/controllers, views in app/views, initializers in config/initializers. No engine, no namespace, no gem dependency at runtime. If you want to change how sessions expire, you edit app/controllers/concerns/authentication.rb. If you want different Rack Attack rules, you edit config/initializers/rack_attack.rb. If you want to add a third role beyond admin and member, you update the enum on User.

There’s no DSL to learn, no configuration file to maintain, no version upgrades to track. The generated code follows Rails conventions because it is Rails code. You can read every line, understand every decision, and change anything that doesn’t fit your project.

This connects to the broader Maquina ecosystem. The generators set up the foundation — auth, security, ops tooling. Maquina Components handles the UI layer with ViewComponent-based partials that the App generator installs automatically. When you start building features on top of this foundation, Rails Simplifier keeps AI-generated code idiomatic, and the MCP Server gives AI tools visibility into your codebase structure.

Each tool is independent. Use one, use all, use none. No lock-in at any layer.

Get Started

Install the gem and run the app generator:

rails new myapp --css tailwind
cd myapp
bundle add maquina-generators --group development
rails generate maquina:app --auth clave
bin/rails db:migrate
bin/dev

Choose --auth clave for passwordless, --auth registration for passwords, or --auth none if you want the infrastructure without authentication.

Full documentation is at maquina.app/documentation/generators. Source code is on GitHub.

--- URL: https://maquina.app/blog/2026/02/recuerd0-source-code-now-available/ Title: Recuerd0 Source Code Is Now Available ---

The self-hosted promise is fulfilled — Recuerd0’s source code is on GitHub.


When we announced Recuerd0, we said the self-hosted version would be available pretty soon. Today it is. The full source code is on GitHub under the OSASSY license.

This is not a stripped-down edition. It’s the same codebase that runs recuerd0.ai — every feature, every endpoint, every migration.

Under the hood

Recuerd0 is a Rails 8.1 application running on Ruby 4.0. The entire stack leans into the One Person Framework philosophy: minimize infrastructure, eliminate external dependencies, ship with confidence.

SQLite for everything. Data, cache, queue, and cable — all backed by SQLite. No Postgres. No Redis. Solid Queue handles background jobs, Solid Cache handles caching, and Solid Cable handles WebSocket connections. One database engine, zero extra services.

No Node.js. The frontend uses Propshaft for asset delivery and Importmaps for JavaScript modules. Hotwire (Turbo + Stimulus) handles interactivity. Tailwind CSS 4 handles styling. The entire frontend pipeline runs without a JS build step.

Full-text search with FTS5. Search is powered by SQLite’s FTS5 extension — no vector database, no embeddings, no RAG pipeline. The index updates on every write, returns results in milliseconds, and is fully deterministic. The agent decides what to search for; the database does the rest.

Memory versioning. Every memory supports a flat branching model — create new versions from any point in history. Soft deletion with 30-day retention means nothing disappears by accident.

Multi-tenancy. The Account model supports multiple tenants. In single-tenant mode (the default for self-hosted), public registration is disabled — you control who has access.

UI components. The interface is built with the maquina-components gem, the same component library used across all Maquina projects.

Getting started

Two paths to self-host:

Docker image. Pull the ready-to-use Docker image and deploy. Configure your environment variables and you’re running.

From source. Clone the repository, configure Kamal 2.x, and deploy to your server. The included Dockerfile and Kamal configuration handle the rest. Thruster sits in front of Puma, and SOLID_QUEUE_IN_PUMA=true runs background jobs in-process — one container, one process, everything included.

Single-tenant mode is the default. No public registration, no setup wizard. Deploy, create your account, start curating context.

License

Recuerd0 is released under the OSASSY license. It’s essentially MIT with one restriction: you can’t take the code and offer it as a competing hosted service. The same model 37signals uses. Deploy it on your infrastructure, modify it, use it internally — free forever.

Not interested in self-hosting?

Recuerd0 SaaS is $15/month for up to 10 users — managed hosting, automatic backups, and updates. Read the full product announcement for the complete story.

The source is on GitHub. Do what you want with it.

View the repository →


Recuerd0 is built by Maquina. Source available under OSASSY license.

--- URL: https://maquina.app/blog/2026/02/announcing-recuerd0/ Title: Announcing Recuerd0: A Knowledge Base for AI Tool Context ---

Organize, version, and serve project context to any LLM — from Claude Code to Cursor to ChatGPT.


Every AI coding tool starts each session with amnesia. Your architecture decisions, naming conventions, and deployment quirks — none of it carries over. You re-explain the same context with the same tools every single day.

The common workarounds are CLAUDE.md files, .cursorrules, AGENTS.md — each tool with its own configuration format. You end up duplicating knowledge across multiple places. They drift apart. Your Claude Code config says one thing; your Cursor rules say another.

Recuerd0 is a dedicated knowledge base for managing the context your AI tools consume. You curate project knowledge once and serve it to every tool via REST API.

Recuerd0 homepage — the knowledge base your AI tools deserve

How it works

Workspaces group knowledge by project or domain. Backend conventions in one workspace, frontend patterns in another, org-wide standards in a shared workspace.

Workspace view showing pinned memories with tags and descriptions

Memories are versioned markdown documents with titles, tags, and full history. When conventions evolve, you create a new version — like Git for context. Branch from any version, track how decisions changed, and never lose the rationale.

Memory detail showing rendered markdown content with version history and tags

Access is through a REST API with Bearer token authentication. Any tool that can make an HTTP request reads from the same source. There’s also a CLI for terminal workflows and a Claude Code plugin for tighter integration.

Search uses the database’s full-text search with millisecond performance. No embeddings, no vector database, no RAG pipeline. The agent decides what to search for and how to refine the search. The index updates on every write, is deterministic, and requires zero infrastructure beyond the database.

Architecture decisions

Human-curated, not auto-captured. Automatic knowledge capture sounds appealing, but it produces noisy results — context-specific fixes that don’t generalize, contradictory items as conventions evolve. The human decides what’s worth persisting. The team reviews and evolves it.

Tool-agnostic by design. We built an API, not a plugin for one tool. Your knowledge base survives any tool change. Claude Code, Cursor, ChatGPT, Windsurf, custom scripts, CI/CD pipelines — same context, same source.

Small and focused. Recuerd0 is designed for a small set of focused memories per workspace philosophy, not thousands of files. The constraint forces curation. When the workspace is focused, the right answer is obvious without sophisticated search algorithms.

Pricing

Recuerd0 SaaS is $15/month for up to 10 users. Managed hosting, automatic backups, updates, and email support. Create an account and start in minutes at recuerd0.ai.

Teams of 6 or more can contact us for custom plans.

Self-hosted is available under the OSASSY license — the same model 37signals uses for Fizzy. It’s essentially MIT with one addition: you can’t take the code and offer it as a competing hosted service. Deploy on your server, modify the code, use it internally — free forever.

The self-hosted version is not available at launch, but it will be available pretty soon.

Get started

The API documentation covers every endpoint. The CLI reference has installation and commands. The agent workflows guide shows how to integrate with Claude Code, Cursor, and other tools.

Context engineering has become a core developer skill. It deserves a dedicated tool.

Start with Recuerd0 SaaS →


Recuerd0 is built by Maquina. Source available under OSASSY license.

--- URL: https://maquina.app/blog/2026/02/maquina-components-0-4-0-turbo-compatibility/ Title: Maquina Components 0.4.0: Taming Turbo ---

I was working on a Rails application—standard CRUD with a sidebar and a few interactive menus. Everything worked on first load. Then I navigated away and came back. The sidebar was gone. I opened a dropdown, clicked a Turbo link, hit the back button. The dropdown was still open, sitting there on top of a page that had already moved on.

If you’ve built anything with Turbo and Stimulus beyond basic forms, you’ve likely seen this. Components work fine on full page loads, but Turbo introduces a different lifecycle. Pages get cached mid-state, morphs overwrite client-side changes with stale server HTML, and your UI ends up stuck in states it should have left behind.

Fixing this in Maquina Components is what version 0.4.0 is about.

The Teardown Pattern

The core problem is described well by Better Stimulus. When Turbo navigates away from a page, it takes a snapshot of the DOM before leaving. When the user returns, Turbo shows that snapshot first. Any DOM changes your Stimulus controllers made—open menus, expanded panels, loading classes—get frozen into the cache.

The standard Stimulus disconnect callback handles general cleanup, but it doesn’t distinguish between “the element was removed from the DOM” and “Turbo is about to cache this page.” You need both.

The Teardown pattern adds a teardown method to controllers, triggered by Turbo’s turbo:before-cache event. Every controller that manipulates the DOM can opt in, resetting its visual state before Turbo takes the snapshot. This keeps disconnect clean for general lifecycle concerns and gives Turbo-specific rollback its own dedicated path.

This release applies that pattern across the interactive components in the library.

The sidebar was the hardest to get right. It had three separate issues interacting with each other.

Random IDs broke morphing. The sidebar generated IDs like sidebar-a3f9b2 on every render. Turbo’s idiomorph algorithm matches elements by ID—when the ID changes every time, idiomorph can’t find the element and treats it as new. Every morph was destroying and recreating the sidebar from scratch. The fix: deterministic IDs. sidebar-left and sidebar-right, consistent across renders. The sidebar provider also gets a stable ID (sidebar-provider by default).

Morphs overwrote client state. The sidebar stores its open/closed state in a cookie so it persists across page loads. During a Turbo morph, the server sends back HTML with the default state—it doesn’t know about the cookie. Idiomorph applies the server HTML, and the sidebar collapses even though the user had it open.

The fix adds a turbo:before-morph-element listener with a _morphing guard flag. When a morph happens, the controller reads the cookie (the source of truth on the client), reasserts the correct state, and strips the sidebar-loading class that the server HTML reintroduces.

Layout shift on desktop. When Stimulus initialized and switched the sidebar from its mobile offcanvas mode to the desktop collapsible mode, there was a visible jump. The transition happened after the browser had already painted. This release smooths that handoff so the mode switch doesn’t cause a flash.

The Yield Trap

The second category of fixes has nothing to do with Turbo. It’s a Rails rendering behavior that caught me off guard.

Nine partials in the library used the standard block pattern:

<%= render "components/card/description" do %>
  <p>Custom HTML</p>
<% end %>

This works when you always pass a block. But render the partial without a block and yield inside it doesn’t return nothing—it renders the entire page’s content into the partial. Rails treats the missing block as a signal to yield the page-level content instead.

The result: components rendering the full page body inside a card title or a toast message. It only shows up in specific usage patterns, and when it does, the output looks completely wrong with no obvious cause.

The fix replaces yield with an explicit content: parameter in all nine affected partials:

The five toast helper methods no longer accept blocks either.

Breaking Changes

This is a minor version bump with breaking changes:

Migration

The content parameter change is mechanical. Find every block-style call to the affected partials and wrap the content with capture:

<%= render "components/card/description" do %>
  <p>Custom HTML</p>
<% end %>

<%= render "components/card/description",
      content: capture { %>
  <p>Custom HTML</p>
<% } %>

For sidebar IDs, if you reference specific sidebar element IDs in JavaScript or tests, update them to sidebar-left or sidebar-right.

Upgrading

bundle update maquina_components

What This Reinforced

Turbo is not a transparent layer over page loads. It’s a different execution model. Any Stimulus controller that touches the DOM needs to account for caching, morphing, and the gap between what the server renders and what the client has changed since. The Teardown pattern should be the default starting point for any controller that does more than read values.

The yield behavior in Rails partials was a genuine surprise. It’s documented, but it’s a quiet trap when you have optional block content. Explicit parameters are safer.

Documentation

Source

--- URL: https://maquina.app/blog/2026/01/maquina-0-3-1-calendar-date-picker-claude-skills/ Title: Maquina 0.3.1: Calendar, Date Picker & Claude Code Skills ---

This month brings updates across the Maquina ecosystem: new Calendar and Date Picker components for Rails, two Claude Code skills for AI-assisted development, and live interactive previews for all components in the documentation.

Maquina Components 0.3.1

Building on version 0.3.0, this release adds two components for date selection: Calendar and Date Picker.

Calendar

An inline calendar for single date or range selection. Useful when you need the full calendar visible—booking flows, availability displays, or any context where date proximity matters.

<%= render "components/calendar",
      mode: :range,
      selected: Date.today,
      selected_end: Date.today + 5 %>

For form integration, the calendar generates hidden inputs automatically:

<%= form_with model: @booking do |f| %>
  <%= render "components/calendar",
        mode: :range,
        input_name: "booking[check_in]",
        input_name_end: "booking[check_out]" %>
<% end %>

Features: Single or range selection, min/max date constraints, disabled dates, week start configuration, and direct form integration with hidden inputs.

Date Picker

A button that opens a calendar in a popover. Better for forms where space is limited and you don’t need the calendar always visible.

<%= render "components/date_picker",
      mode: :single,
      placeholder: "Select a date",
      input_name: "event[date]" %>

Range selection works the same way:

<%= render "components/date_picker",
      mode: :range,
      placeholder: "Select date range",
      input_name: "start_date",
      input_name_end: "end_date" %>

Features: Single or range selection, pre-selected date display, min/max boundaries, disabled state, and customizable placeholders.

When to Use Which

Use Case Component
Booking calendar with visible availability Calendar
Date field in a form Date Picker
Date range with context (prices, events) Calendar
Quick date selection in limited space Date Picker

Live Previews

Maquina Components live previews showing components in green theme

The documentation site now includes live, interactive previews for all components. Visit any component page in the documentation to see working examples in light and dark themes, multiple color variations, and code ready to copy.

For a complete showcase, the live demo application shows all components working together with sample data.

Upgrading

bundle update maquina_components

No generator changes required for existing installations.

Claude Code Skills

Two new skills for AI-assisted Rails development.

Maquina UI Standards

Teaches Claude how to build UIs with maquina_components. Without guidance, Claude generates generic Rails patterns—plain divs, inline styles, inconsistent markup. With this skill, Claude generates code using your actual component library.

Includes: Component catalog with 20+ components, form patterns, layout patterns, Turbo integration, and accessibility guidelines.

/plugin marketplace add maquina-app/rails-claude-code
/plugin install maquina-ui-standards@maquina

Ask Claude “Create a users index view with a table” and get:

<%= render "components/card" do %>
  <%= render "components/card/header" do %>
    <%= render "components/card/title", text: "Users" %>
  <% end %>
  <%= render "components/card/content" do %>
    <%= render "components/table" do |t| %>
      <% t.header do %>
        <% t.head_cell "Name" %>
        <% t.head_cell "Email" %>
      <% end %>
    <% end %>
  <% end %>
<% end %>

Documentation: Maquina UI Standards

Rails Simplifier

Refines Rails code following 37signals patterns and the One Person Framework philosophy.

What it does: Converts service objects to model methods, transforms custom actions to CRUD resources, moves logic from controllers to models, detects N+1 queries, and applies Rails conventions like I18n and Time.current.

/plugin marketplace add maquina-app/rails-claude-code
/plugin install rails-simplifier@maquina

Example prompts:

> Review recent changes using the rails-simplifier agent
> Use rails-simplifier to review the bookings controller

Documentation: Rails Simplifier

Source

All projects are MIT licensed:

--- URL: https://maquina.app/blog/2026/01/claude-skill-for-maquina-components/ Title: Claude Skill for Maquina Components ---

When I started extracting and standardizing maquina_components from real production applications, I was also experimenting with AI-assisted development. The two efforts ran in parallel—building a consistent component library while trying to get Claude to help me use it.

The results were mixed. Every time I asked Claude to build a view or implement a form, it was back and forth. “Use the card partial, not a div.” “The input needs a data attribute.” “That’s not how the combobox works.” I spent as much time correcting the AI as I would have spent writing the code myself.

The same friction appeared when writing specs. I’d describe a feature and Claude would suggest generic Rails patterns instead of the components I had available. It didn’t know about the library. How could it?

The Skill Experiment

When Anthropic released the Skills functionality, I wondered if it was the right tool for this problem. Skills let you teach Claude project-specific knowledge—conventions, patterns, APIs. Exactly what was missing.

I created a first version: a structured reference with component examples, form patterns, layout conventions, and Turbo integration guides. Added it to my projects and started using it.

It worked. Claude started generating code that matched my conventions. The combobox had proper keyboard navigation. Forms used the right data attributes. Turbo Streams updated components correctly. The back-and-forth dropped significantly.

I kept the skill private. It was tied to my workflow, my projects. Not ready for others.

Making It Public

Yesterday I published Maquina Components 0.3.0 with Combobox and Toast. Shortly after, someone asked if I had an MCP server for the components.

I replied that I had something better—a Claude Skill that I’d been using for while now. It was working great with the gem.

So I decided to open source it.

What the Skill Provides

A complete reference for building UIs with maquina_components:

Reference Purpose
Component catalog All 15+ components with ERB examples
Form patterns Validation, error handling, inline layouts
Layout patterns Sidebar navigation, page structure
Turbo integration Frames, Streams, component updates
Spec checklist Review criteria for UI quality

Installation

Create a skills directory in your Rails project:

cd your-rails-app
mkdir -p .claude/skills

Download the skill from the maquina_components repository and copy it to .claude/skills/maquina-ui-standards/.

Then update your CLAUDE.md to reference it:

## UI Components

This project uses maquina_components for UI. Before implementing views,
forms, or interactive components, read the UI standards skill:

.claude/skills/maquina-ui-standards/SKILL.md

Always consult the skill when:
- Creating or modifying views
- Implementing forms
- Adding interactive components
- Building layouts with sidebar/header patterns
- Working with Turbo Streams that update UI

Usage

Once installed, ask Claude naturally:

Create the users index view with a table showing name, email, and status.
Implement the project form with name, description, and a framework combobox.
Review this view against the maquina UI standards and suggest improvements.

The generated code matches what you’d write manually—just faster, and without the back-and-forth.

Source

The skill is included in the maquina_components repository under MIT license. Updates follow gem releases.

--- URL: https://maquina.app/blog/2026/01/maquina-components-0.3.0-combobox-and-toast/ Title: Maquina Components 0.3.0: Combobox and Toast ---

Version 0.3.0 of Maquina Components adds two frequently requested interactive components: Combobox and Toast.

Both components follow the same philosophy as the rest of the library—ERB partials, Tailwind CSS, and Stimulus controllers only where necessary.

Combobox

Combobox

An autocomplete input with a searchable dropdown list. Useful when selecting from many options—countries, users, tags, or any list that benefits from filtering.

<%= combobox placeholder: "Select framework..." do |cb| %>
  <% cb.trigger %>
  <% cb.content do %>
    <% cb.input placeholder: "Search..." %>
    <% cb.list do %>
      <% cb.option value: "rails" do %>Ruby on Rails<% end %>
      <% cb.option value: "django" do %>Django<% end %>
      <% cb.option value: "phoenix" do %>Phoenix<% end %>
    <% end %>
    <% cb.empty %>
  <% end %>
<% end %>

For simpler use cases, the data-driven helper builds the entire structure from an array:

<%= combobox_simple placeholder: "Select country...",
                     name: "user[country]",
                     options: Country.all.map { |c| { value: c.code, label: c.name } } %>

Features

Requirements

The Combobox uses the HTML5 Popover API for light-dismiss behavior. Most modern browsers support it natively:

Browser Version
Chrome 114+
Edge 114+
Safari 17+
Firefox 125+

For older browsers, add the popover polyfill:

npm install @oddbird/popover-polyfill
// app/javascript/application.js
import "@oddbird/popover-polyfill"

Toast

Combobox

Non-intrusive notifications that appear temporarily and dismiss automatically. Ideal for form submission feedback, background task completion, or any transient message.

Server-Side with Flash Messages

The most common pattern—render Rails flash messages as toasts:

<%= render "components/toaster", position: :bottom_right do %>
  <%= toast_flash_messages %>
<% end %>
# In your controller
flash[:success] = "Profile updated successfully!"
redirect_to @user

Flash types map automatically to toast variants: :success, :error, :warning, :info.

JavaScript API

For dynamic notifications without a page reload:

Toast.success("Changes saved!")

Toast.error("Connection lost", {
  description: "Please check your internet connection."
})

Toast.warning("Session expiring", { duration: 10000 })

// Dismiss programmatically
const id = Toast.info("Processing...")
Toast.dismiss(id)

With Turbo Streams

Append toasts to the container in Turbo Stream responses:

<%= turbo_stream.append "toaster" do %>
  <%= toast :success, "Post published!" %>
<% end %>

Features

Requirements

Toast requires Stimulus for the auto-dismiss timer and JavaScript API. Add the controller to your Stimulus application:

// app/javascript/application.js
import { Application } from "@hotwired/stimulus"
import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"

const application = Application.start()
eagerLoadControllersFrom("controllers", application)

Upgrading

bundle update maquina_components

No generator changes are required. Both components use the existing theme variables.

See Them in Action

To explore Combobox, Toast, and all other components with demo data, clone the repository and run the dummy application:

git clone https://github.com/maquina-app/maquina_components.git
cd maquina_components/test/dummy
bin/dev

Then visit http://localhost:5300 to interact with the full component showcase.

Documentation

Source

The gem is MIT licensed. Source and issues on GitHub.

--- URL: https://maquina.app/blog/2025/12/rails-mcp-server-1-5-0-security-hardening/ Title: Rails MCP Server 1.5.0: Security Hardening and Sandboxed Environment Support ---

Open source projects get better when people contribute back. Rails MCP Server 1.5.0 is a direct result of that—a release shaped significantly by a community contribution that I didn’t write.

The most important change in this version is a comprehensive security overhaul contributed by GitHub user hellvinz through PR #25. It’s the kind of work that doesn’t get enough recognition.

The Security Contribution

When you give an AI model access to your codebase through MCP tools, security matters. The execute_ruby sandbox already restricted dangerous operations, but the file-accessing analyzers needed more rigorous input validation.

PathValidator Module

A centralized validation layer now protects all file-accessing analyzers. Path traversal attempts are blocked. Sensitive files are filtered automatically. The implementation is clean:

# Path traversal attempts are blocked
get_file(path: "../../../etc/passwd")
# => "Access denied: Path is outside the project directory"

# Sensitive files are filtered
list_files(pattern: "config/*.key")
# => master.key, credentials.yml.enc excluded from results

The validator catches:

Injection Prevention

Shell commands now use IO.popen with array arguments instead of string interpolation. Table names in schema queries are validated against a strict pattern. These changes close potential injection vectors that existed in earlier versions.

CI Infrastructure

Beyond the code changes, hellvinz added security infrastructure I should have set up from the start:

This kind of contribution takes real effort. Reviewing an unfamiliar codebase, identifying gaps, implementing fixes that don’t break existing functionality, unglamorous work that makes the project better for everyone who uses it.

I’m grateful for the contribution.

Sandboxed Environment Support

AI coding agents increasingly run in sandboxed environments—containers or restricted shells where they can only access the current project directory. GitHub Copilot Agent and Claude Code Agent both work this way.

Previous versions of Rails MCP Server assumed access to a user home directory for configuration files. That doesn’t work in a sandbox.

The --single-project flag solves this. It tells the server to use the current working directory as the only project, skipping configuration files entirely:

rails-mcp-server --single-project

GitHub Copilot Agent configuration goes in .vscode/mcp.json:

{
  "servers": {
    "rails-mcp": {
      "command": "rails-mcp-server",
      "args": ["--single-project"]
    }
  }
}

Claude Code Agent can use the same flag. The server detects it’s running in a Rails directory and works immediately—no setup required.

This also simplifies CI/CD pipelines and any environment where you want the server to just work with the current directory.

The Copilot Agent documentation covers the setup in detail.

Simplified Project Configuration

Previous versions required manual configuration in ~/.config/rails-mcp/projects.yml. That still works, but 1.5.0 adds flexibility:

Method Use Case
--single-project flag Sandboxed agents (Copilot, Claude Code), CI/CD
RAILS_MCP_PROJECT_PATH env var Explicit path control
Auto-detection Finds Rails apps from Gemfile, engines from gemspec
projects.yml Multiple projects with named references

The server now auto-detects Rails applications by checking for a Gemfile with the rails gem, and Rails engines by looking for gemspec files with Rails dependencies. When only one project is available, it switches automatically.

Rails 8.1 Compatibility

Rails 8.1 changed the internal callback API. The analyze_controller_views tool was calling callback.options to extract :only and :except conditions, but that method no longer exists.

The fix maintains backward compatibility:

callbacks: controller._process_action_callbacks.map { |cb|
  h = { kind: cb.kind.to_s, filter: cb.filter.to_s }
  if cb.respond_to?(:options)
    h[:only] = Array(cb.options[:only]).map(&:to_s)
    h[:except] = Array(cb.options[:except]).map(&:to_s)
  end
  h
}

This works with Rails 6.0 through 8.1. The callback conditions are extracted when available, omitted when not.

Other Changes

Error messages now include hints. When you ask for a model named users instead of User, the error explains the naming convention. Small things that reduce friction.

Parameter passing in execute_tool is fixed. The params schema now generates correctly for MCP clients, so tools like analyze_models can actually receive their parameters. This was a real bug that made the tool harder to use than it should have been.

Input validation for load_guide prevents path traversal in guide names. Another gap that hellvinz’s security review prompted me to address.

Breaking Change

The load_guide analyzer renamed its parameter from guides to library:

# Before (1.4.x)
execute_tool("load_guide", { guides: "rails", guide: "active_record" })

# After (1.5.0)
execute_tool("load_guide", { library: "rails", guide: "active_record" })

The change clarifies that you’re selecting a documentation library (rails, turbo, stimulus, kamal, custom), not multiple guides. It’s a small breaking change, but the naming is more accurate.

Upgrading

gem update rails-mcp-server

If you’re using Claude Desktop, restart it to pick up the new version. The server binary path in your configuration doesn’t change.

For new installations:

gem install rails-mcp-server
rails-mcp-config

The interactive configuration tool handles Claude Desktop setup, project registration, and guide downloads.

What’s Next

The MCP specification continues to evolve. As more AI tools adopt the protocol, Rails MCP Server will adapt to support them.

If you find issues or have ideas, the issue tracker is open. Pull requests are welcome. As this release shows, community contributions make a real difference—sometimes more than you might expect.

--- URL: https://maquina.app/blog/2025/12/announcing-maquina-components-opinionated-ul-for-rails-applications/ Title: Announcing Maquina Components: Opinionated Ul for Rails Applications ---

Rails has opinions about most things. Database migrations, routing, asset handling, background jobs. But when it comes to building user interfaces, you’re on your own.

The framework gives you excellent primitives: importmaps, Stimulus, Turbo. But no default components. No standard way to build a button, a card, or a data table. Every Rails developer reinvents these from scratch.

Maquina Components is my attempt to fill this gap—not the definitive solution, but one practical approach that works for how I build applications.

Why This Exists

I started building components inspired by shadcn/ui for production Rails applications—dashboards, admin interfaces, internal tools. Over time, these components spread across multiple projects and became inconsistent: different APIs, different styling approaches, different levels of completeness.

It was time to extract the elements I use most and give them a cohesive API and consistent styling.

The Technical Choices

I chose ERB partials with Tailwind CSS and Stimulus controllers for interactive elements. For static components like form inputs, pure CSS with data attributes is enough.

<%= render "components/card" do %>
  <%= render "components/card/header" do %>
    <%= render "components/card/title", text: "Projects" %>
  <% end %>
  <%= render "components/card/content" do %>
    <%= render "components/table", collection: @projects %>
  <% end %>
<% end %>

I’m aware of alternatives like ViewComponent and Phlex. The projects I extracted these components from didn’t use them. I see the benefits of using a Ruby class to render UI, but bringing either library into a project is a commitment—not all teams are ready to make it.

The reason isn’t technical. It’s the perception of moving away from “the Rails way.” ERB partials are what Rails developers learn first. They’re simple, they work, and everyone understands them immediately.

What’s Included

Twelve components extracted from production applications:

Category Components
Layout Sidebar, Header
Content Card, Alert, Badge, Table, Empty State
Navigation Breadcrumbs, Dropdown Menu, Pagination
Interactive Toggle Group
Forms Input, Select, Checkbox, Button (via data attributes)

Each component follows the shadcn/ui theming convention with CSS variables. Light and dark mode work out of the box.

Composability Over Convenience

These components are intentionally small. A card is five partials: wrapper, header, title, description, content, footer. That’s more code to write than a single <%= card(...) %> helper.

But composition is the point. You take these partials and build larger, application-specific components. A ProjectCard that combines Card + Badge + Button. A UserTable that extends Table with custom columns. There are no limits because you own the abstraction layer.

What I Didn’t Build

I didn’t port shadcn/ui one-to-one. I extracted only the components I actually use. This is a practical toolkit, not a complete design system.

If you need modals, tooltips, date pickers, or complex form builders—those aren’t here yet. They might come later if I need them in my own projects.

The Rails Frontend Landscape

There’s no single UI kit that dominates Rails development. The community has fragmented across different approaches:

Evil Martians has written extensively about modern frontend in Rails. Their work with ViewComponent and Inertia.js is excellent, but those approaches add dependencies I prefer to avoid.

Maquina Components takes a different path: standard ERB, standard Tailwind, minimal JavaScript. If you’re building server-rendered Rails applications and want components that don’t require learning a new paradigm, this might work for you.

Alternatives

If this approach doesn’t resonate, here are alternatives worth exploring:

Getting Started

bundle add maquina_components
rails generate maquina_components:install

The generator adds the engine CSS, theme variables, and a helper file for icon customization.

Browse the documentation for examples and API details. The test/dummy application in the repository shows all components with demo data.

Open Source

Maquina Components is MIT licensed. The source is on GitHub.

If you try it and have feedback, I’d like to hear it. If this isn’t for you, that’s okay too. Rails is big enough for many approaches.

--- URL: https://maquina.app/blog/ Title: Blog ---

Featured

equipr installing a skill into multiple coding agents from one command
Monday, August 10, 2026
equipr is out: one Go binary that installs skills, commands, and MCP servers into Claude Code, Codex, OpenCode, and Pi, with no plugin registration.
Mario Alberto Chávez Cárdenas
Rails MCP Server 2.0.0 — execute_ruby removed, introspection-only
Tuesday, August 4, 2026
Rails MCP Server 2.0.0 removes the execute_ruby tool entirely, because the reasoning that justified it in 2025 stopped holding once agentic coding tools could run Ruby themselves.
Mario Alberto Chávez Cárdenas
Rails MCP Server 1.6.0 release announcement showing execute_ruby sandbox hardening
Monday, August 3, 2026
Version 1.6.0 hardens the execute_ruby sandbox by closing real file-read bypasses, rolling back database writes, killing runaway processes, and asking before running dual-use code, plus manager-agnostic Ruby resolution and namespaced model fixes.
Mario Alberto Chávez Cárdenas
Monday, August 10, 2026
Mario Alberto Chávez Cárdenas

equipr: Cross-Agent Skill and MCP Server Manager

equipr is out: one Go binary that installs skills, commands, and MCP servers into Claude Code, Codex, OpenCode, and Pi, with no plugin registration.

Tuesday, August 4, 2026
Mario Alberto Chávez Cárdenas

Why I Removed execute_ruby from Rails MCP Server

Rails MCP Server 2.0.0 removes the execute_ruby tool entirely, because the reasoning that justified it in 2025 stopped holding once agentic coding tools could run Ruby themselves.

Monday, August 3, 2026
Mario Alberto Chávez Cárdenas

Rails MCP Server 1.6.0: Making "Read-Only" Actually Mean Read-Only

Version 1.6.0 hardens the execute_ruby sandbox by closing real file-read bypasses, rolling back database writes, killing runaway processes, and asking before running dual-use code, plus manager-agnostic Ruby resolution and namespaced model fixes.

Monday, July 27, 2026
Mario Alberto Chávez Cárdenas

Maquina Components 0.6.0: Themeable Beyond Color

Radius, elevation and focus rings become design tokens in this Rails + Tailwind component library, engine CSS moves into @layer components, and utilities win.

Monday, July 20, 2026
Mario Alberto Chávez Cárdenas

Introducing Nexo: the harness for Ruby agents

Nexo gives the RubyLLM ecosystem one front door: safe sandboxing, explicit permissions, and a real job primitive for agent runs. What it is, and why it exists.

Sunday, July 12, 2026
Mario Alberto Chávez Cárdenas

Maquina Components 0.5.0: Drawer and Scaffold Templates

A Turbo-aware Drawer component and a scaffold_templates generator that styles rails g scaffold output — Maquina Components 0.5.0, built mostly by contributors.

Monday, July 6, 2026
Mario Alberto Chávez Cárdenas

Introducing Fragua: the harness already composed for Rails

Fragua, an AI agent orchestrator for Rails — turn an idea into a shipped app through research, plan, spec, and ship, with agents that respect the craft. Now taking private beta requests.

Thursday, June 11, 2026
Mario Alberto Chávez Cárdenas

recuerd0 Now Has an MCP Server — and a Better Look

recuerd0 now has a remote MCP server with OAuth 2.1, a markdown editor based on 37signals' House MD, and a refined UI built on Maquina Components.

Tuesday, April 7, 2026
Mario Alberto Chávez Cárdenas

Recuerd0 Now Reads Like a Filesystem

Recuerd0's API now lets AI agents grep, glob, and read memories in line ranges — the same primitives Claude Code already uses. Plus categories, links, and caching.

Monday, March 23, 2026
Mario Alberto Chávez Cárdenas

MVP Creator: From Idea to Documents in Three Prompts

Use MVP Creator, a Claude Code plugin, to generate research reports, business plans, brand guides, and technical specs for new Rails projects. Three prompts, six documents.

Friday, March 13, 2026
Mario Alberto Chávez Cárdenas

Maquina Generators: From rails new to Production-Ready

Rails generators for authentication, job queues, error tracking, and security. No runtime dependency — generate once, own the code forever.

Saturday, February 21, 2026
Mario Alberto Chávez Cárdenas

Recuerd0 Source Code Is Now Available

The Recuerd0 source code is now on GitHub. Built with Rails 8, SQLite, and Hotwire — here's a look under the hood.

Sunday, February 15, 2026
Mario Alberto Chávez Cárdenas

Announcing Recuerd0: A Knowledge Base for AI Tool Context

Versioned knowledge base for AI coding tools. Curate project context once, serve it via REST API to Claude Code, Cursor, and ChatGPT. SaaS or self-hosted.

Friday, February 13, 2026
Mario Alberto Chávez Cárdenas

Maquina Components 0.4.0: Taming Turbo

Turbo Drive and Morph compatibility fixes for sidebars, plus a Rails partial rendering fix for block content in 9 components.

Friday, January 23, 2026
Mario Alberto Chávez Cárdenas

Maquina 0.3.1: Calendar, Date Picker & Claude Code Skills

Maquina Components 0.3.1 adds Calendar and Date Picker for Rails. Plus Claude Code skills for consistent UI generation and Rails code simplification.

Thursday, January 8, 2026
Mario Alberto Chávez Cárdenas

Claude Skill for Maquina Components

Teach Claude how to build consistent UIs in Rails applications using maquina_components. A skill for AI-assisted development.

Wednesday, January 7, 2026
Mario Alberto Chávez Cárdenas

Maquina Components 0.3.0: Combobox and Toast

Two new interactive components for Rails applications. Searchable dropdowns and non-intrusive notifications.

Monday, December 29, 2025
Mario Alberto Chávez Cárdenas

Rails MCP Server 1.5.0: Security Hardening and Sandboxed Environment Support

Version 1.5.0 brings comprehensive security improvements from community contributor hellvinz, plus support for sandboxed AI agents like GitHub Copilot and Claude Code.

Tuesday, December 16, 2025
Mario Alberto Chávez Cárdenas

Announcing Maquina Components: Opinionated Ul for Rails Applications

Production-ready ERB partials styled with Tailwind CSS 4.0. Extracted from real applications.

--- URL: https://maquina.app --- --- URL: https://maquina.app --- --- URL: https://maquina.app --- --- URL: https://maquina.app --- --- URL: https://maquina.app --- --- URL: https://maquina.app Title: Maquina --- --- URL: https://maquina.app/bridgetown_quick_search/index.json Title: Index --- [ { "id": "blog-2026-08-equipr-cross-agent-skill-manager", "title": "equipr: Cross-Agent Skill and MCP Server Manager", "collection": { "label": "posts", "name": "Blog" }, "categories": "Announcements, Release, Tools", "tags": "", "url": "/blog/2026/08/equipr-cross-agent-skill-manager/", "content": "equipr installs skills, commands, and MCP servers from a marketplace or an Agent Plugins source into whichever coding agents are present on a machine: Claude Code, Codex, OpenCode, and Pi. It works through each agent’s own personal config surfaces rather than a plugin system. It is a single Go binary, MIT licensed, and available now via Homebrew or as a standalone install script.\n\nThe problem it addresses\n\nClaude Code has a marketplace mechanism for distributing and updating skills that none of the other three agents come close to matching. Codex, OpenCode, and Pi have no built-in equivalent. The alternatives are copying a skill’s files by hand, which then has to be kept current on its own since nothing does that automatically, or installing an npm package for the skills some maintainers publish that way. The Agent Plugins specification exists to standardize the package format itself, but it does not yet define how any individual agent installs from a source or keeps that installation up to date. That part is still left to each agent’s own tooling, or to something else.\n\nThe same gap shows up again with isolated per-agent containers, of the kind Fragua uses: each container needs the same sources installed, and each one is a separate place that update has to reach.\n\nWhat equipr resolves\n\nequipr treats a source as one of two shapes. A repository with .claude-plugin/marketplace.json at its root is a marketplace holding one or more plugins. A repository with plugin.json at its root is a single Agent Plugins-conformant package. Both resolve to the same internal model, addressed the same way:\n\n<source-id>/<plugin>:<component>\n\n\n$ equipr add https://github.com/coreyhaines31/marketingskills\nFetching https://github.com/coreyhaines31/marketingskills\nAdded marketingskills (marketplace, fetched via git) with 1 plugin(s)\n - marketing-skills 2.10.0\n\n$ equipr list\nmarketingskills marketplace\n marketing-skills 2.10.0 49 skills\n\n$ equipr install marketingskills/marketing-skills:seo-audit --yes\nInstalled 1 component(s) into 3 agent(s):\n [claude-code] seo-audit (copy -> ~/.claude/skills/seo-audit)\n [opencode] seo-audit (symlink -> ~/.config/opencode/skills/seo-audit)\n [pi] seo-audit (symlink -> ~/.pi/agent/skills/seo-audit)\n\n\nequipr status reports whether each recorded install still matches its source. equipr update re-fetches every registered source and re-applies anything that changed. equipr doctor reports broken symlinks and orphaned records.\n\nWhere each agent differs\n\nThe four agents agree on very little beyond the skill file format itself. A SKILL.md is the one point of real convergence across all four. Everywhere else, they differ:\n\n\n \n \n Agent\n Skills directory\n MCP config\n \n \n \n \n Claude Code\n ~/.claude/skills/\n ~/.claude.json\n \n \n Codex\n ~/.codex/skills/\n ~/.codex/config.toml\n \n \n OpenCode\n ~/.config/opencode/skills/\n ~/.config/opencode/opencode.json\n \n \n Pi\n ~/.pi/agent/skills/\n ~/.config/mcp/mcp.json\n \n \n\n\nThree different config formats, and OpenCode’s MCP schema is not merely a different filename: it requires an explicit type and a command array where Claude Code infers both from a single string. equipr’s install step translates a source’s MCP configuration into each target’s native shape rather than writing one format everywhere.\n\nInstalling into each agent\n\nequipr places skill and command files where each agent expects them, and merges MCP servers into each agent’s existing config without disturbing anything else already there: no unrelated key is touched, no unrelated setting is reformatted. Installing is the same command regardless of which agent it’s going into; equipr absorbs the difference in how each one stores things.\n\nnpm sources without an npm install\n\nequipr accepts an npm package as a source. It does not run npm install. It runs npm pack, extracts the resulting tarball into its own cache alongside git and archive sources, and reads plugin.json, skills/, commands/, and mcp.json out of the result. No dependency tree is installed alongside it, and nothing registers as a plugin or a global package in the process.\n\nWhat equipr does not do\n\nequipr never registers itself as a native plugin inside any agent: no entry in Claude Code’s /plugin list, no npm-global registration, nothing to unregister if the binary is removed. It writes to personal config directories and stops.\n\nWhat this reinforced\n\nTreating each agent’s personal config surface as the integration point, rather than any agent’s own plugin system, is what let one tool support four incompatible agents from a single codebase. The harder requirement turned out to be the opposite of adding capability: writing less to any given file, and writing it more carefully, specifically the single-key-merge discipline for MCP config. That constraint did more to make the tool trustworthy than any feature did.\n\nInstall\n\ncurl -fsSL https://github.com/maquina-app/equipr/releases/latest/download/install.sh | sh\n\n\nChecksum-verified, installs to /usr/local/bin when writable, ~/.local/bin otherwise. A Homebrew tap is also available: brew install maquina-app/tap/equipr.\n\nThe equipr documentation covers every command, every flag, where each file lands per agent, and exit codes for scripting. Source is on GitHub, MIT licensed.\n\nMaquina’s own Claude Code plugins are distributed as a marketplace equipr can install from: see AI Tools." }, { "id": "blog-2026-08-rails-mcp-server-2-0-0-removing-execute-ruby", "title": "Why I Removed execute_ruby from Rails MCP Server", "collection": { "label": "posts", "name": "Blog" }, "categories": "Announcements, Release, AI Tools", "tags": "", "url": "/blog/2026/08/rails-mcp-server-2-0-0-removing-execute-ruby/", "content": "I built the first version of Rails MCP Server before Claude Code, Codex, and tools like them were the popular way to work with AI on a codebase. Back then, Claude Desktop could talk to me and read whatever I pasted into the chat. Nothing else. If I wanted it to see a model’s associations, check a route, or run a quick query against my own data, I copied and pasted. Every question meant leaving the conversation, going to the terminal, and bringing the answer back by hand.\n\nThat’s the itch Rails MCP Server scratched: let Claude Desktop introspect a Rails project directly (routes, schema, models) without me being the copy-paste layer between every question and its answer. A companion tool, nvim-mcp-server, closed the other half of the loop, letting Claude Desktop write code changes straight into my Neovim buffers instead of me pasting a diff and applying it by hand.\n\nexecute_ruby made sense inside that setup. Claude Desktop had no way to touch my filesystem or run anything on its own. The MCP server was its only hands. Giving it a tool that could run real Ruby inside my running Rails app meant one call could answer what would otherwise take a dozen round trips: an ad-hoc scope, or a quick Model.find to check a hunch. It was the sharpest tool in the server, and it earned its place.\n\nWhat changed\n\nThat gap doesn’t exist for most people building with AI anymore. Claude Code, Codex, and the agentic coding tools that followed sit directly on top of the codebase. They already have a shell and the same Ruby your app runs on. If an agent wants to check User.where(inactive: true).count, it doesn’t need a special MCP tool for that. It can just run it, the way I would from my own terminal.\n\nThat’s most of the reasoning that justified execute_ruby, gone. The tool wasn’t wrong when I built it. It answered a real constraint, and that constraint has largely gone away.\n\nHardening a tool I was already rethinking\n\nI didn’t arrive here in one step. Yesterday’s 1.6.0 release closed several real bypasses in the execute_ruby sandbox: file reads that slipped past validation and symlinks that walked out of the project, on top of database writes that weren’t actually being rolled back. Today’s 1.6.1 went further: a researcher at Pluto Security responsibly disclosed a command-execution path through require \"pty\", and the fix restricted require to a small allowlist of data-only libraries, cutting off that path along with a few adjacent ones.\n\nBoth releases were worth shipping on their own. But writing 1.6.1 is what made the actual problem visible to me: I was patching individual escapes out of a tool whose entire premise is running caller-supplied Ruby with the privileges of the server process. A static scan and some runtime overrides can raise the floor, but they were never going to be a hard boundary. Path validation, transaction rollback, the confirm_risky gate: every fence I’ve put around execute_ruby has been best-effort, and 1.6.0 said so plainly. Best-effort is a reasonable trade when a tool is filling a real gap. It stops paying off once that’s no longer true.\n\nWhat 2.0.0 does\n\nVersion 2.0.0 removes execute_ruby outright, rather than hardening it again. The server is introspection-only now, and its dedicated analyzers already cover the ground execute_ruby was built for:\n\n\n \n \n Instead of execute_ruby for…\n Use\n \n \n \n \n Reading a file\n get_file\n \n \n Finding files\n list_files\n \n \n Routes, schema, models, controllers, env, structure\n get_routes, get_schema, analyze_models, analyze_controller_views, analyze_environment_config, project_info\n \n \n\n\nAd-hoc data queries (a custom scope, a one-off count) are the one thing this doesn’t replace, on purpose. That was always the part of execute_ruby doing the most work and carrying the most risk, and it’s exactly the part an agent sitting on top of your code no longer needs a server tool for.\n\nBootstrap tools drop from four to three: switch_project, search_tools, execute_tool. The internal analyzers haven’t changed. They’re still discovered through search_tools and invoked through execute_tool, same as before.\n\nIf you’re on a client without direct code execution and still want execute_ruby, the 1.6.x line keeps it, now with the 1.6.1 hardening. It isn’t disappearing; it’s just not where the project is headed.\n\nUpgrading\n\ngem update rails-mcp-server\n\n\nIf you have execute_ruby wired into a client’s tool config, remove it from there. Replace file reads with get_file and globs with list_files. Everything else keeps working the way it did.\n\nFor new installations:\n\ngem install rails-mcp-server\nrails-mcp-config\n\n\nWhere this leaves the project\n\nA tool that runs arbitrary code is always going to be the most interesting line in a security report, no matter how many layers sit around it. I’d rather ship a Rails MCP Server that doesn’t have that line at all than one that keeps explaining, release after release, why the latest patch finally closes the gap. What’s gone is the one tool whose risk had stopped being worth what it saved me.\n\nThanks again to Pluto Security for the responsible disclosure that shaped both 1.6.1 and this release. If you find a gap, the issue tracker is open.\n\nLinks\n\n\n GitHub Repository\n RubyGems\n 2.0.0 Release Notes\n 1.6.1 Release Notes\n Security Policy" }, { "id": "blog-2026-08-rails-mcp-server-1-6-0-sandbox-hardening", "title": "Rails MCP Server 1.6.0: Making \"Read-Only\" Actually Mean Read-Only", "collection": { "label": "posts", "name": "Blog" }, "categories": "Announcements, Release, AI Tools", "tags": "", "url": "/blog/2026/08/rails-mcp-server-1-6-0-sandbox-hardening/", "content": "The execute_ruby tool lets an AI model run Ruby in the context of your Rails application. That is genuinely useful. One call can answer a question that would otherwise take a dozen tool round-trips, and it is also the single most dangerous thing this server does. The tool advertises itself as read-only. Version 1.6.0 is about making that promise closer to true.\n\nThis release started with an uncomfortable review of the sandbox. What I found is that “read-only” had holes you could walk through without trying. So 1.6.0 closes them, adds several layers of defense behind them, and, importantly, is honest about what the sandbox is and isn’t.\n\nWhat the sandbox actually is\n\nWorth stating plainly, because it frames everything below: execute_ruby runs your code through bin/rails runner. That means real Ruby, with your full application loaded and a live database connection. The sandbox is a combination of static analysis (a scan for dangerous patterns) and runtime overrides of File, IO, Dir, and Kernel.\n\nThat is defense-in-depth, not a locked box. It raises the floor; it is not an isolation boundary. Keeping that distinction visible is part of what 1.6.0 fixes. The previous framing implied more safety than the implementation delivered.\n\nThe holes that were closed\n\nFile reads leaked through the siblings. The sandbox overrode File.read and File.open, but not File.readlines, File.binread, File.foreach, or any of the IO equivalents. Those are separate entry points, and they read arbitrary paths:\n\n# Blocked before 1.6.0:\nFile.read(\"/etc/passwd\") # => PATH ERROR\n\n# Wide open before 1.6.0:\nIO.read(\"/etc/passwd\") # read anything\nFile.readlines(\"/etc/passwd\")\n\n\nEvery read entry point now routes through the same path validation.\n\nRaw readers, still exposed: the overrides kept the originals around as public aliases like File.original_read, so user code could just call the un-sandboxed method directly. Those are gone from the public surface now; the native handles live in private constants the sandbox uses internally.\n\nSymlinks could point out of the project. Path validation expanded paths but never resolved symlinks, so a link inside the project that pointed at /etc/passwd passed the check and then read the target. Validation now resolves the real path first, and the allowlist for system data (the timezone directories Rails needs for Time.zone) is matched against canonical locations so it keeps working on macOS, where /usr/share/zoneinfo is itself a symlink.\n\nHalf-blocked ENV: the scan rejected ENV[...] and ENV.fetch, but ENV.to_h, ENV.values_at, and ENV.each walked straight past it and handed over every secret in the process environment. The scan now rejects ENV access broadly, while still leaving Rails.env alone.\n\nThe layers added behind them\n\nClosing bypasses is necessary but not sufficient. A blocklist can always be out-metaprogrammed. So 1.6.0 adds controls that don’t depend on catching every trick.\n\nDatabase writes are rolled back. This is the big one, because “read-only” was never true for data. Nothing stopped User.delete_all or a raw DELETE. Now your code runs inside a transaction that is always rolled back:\n\n# This executes, returns a count, and then is undone:\nUser.where(inactive: true).delete_all\n# => 42 (and the 42 rows are still there)\n\n\nIt is harm reduction, not a guarantee (DDL auto-commits on some adapters like MySQL, and after_commit callbacks don’t fire), but the common accident is now a no-op instead of data loss.\n\nThe timeout actually stops the code. The previous timeout wrapped the subprocess call and, when it fired, stopped waiting, while the runaway rails runner kept going, orphaned. The command now runs in its own process group, and a timeout kills the whole group. A runaway query or an infinite loop is terminated, not abandoned.\n\nDual-use constructs ask first. Some things are legitimately useful and also the classic sandbox-escape tools: send, public_send, const_get, Kernel#open. Rather than silently allowing them or hard-blocking them, the tool now stops and explains:\n\nCONFIRMATION REQUIRED: This code uses constructs that can bypass\nthe sandbox's static safety checks:\n - `send`: dynamic dispatch can invoke methods the static scan\n cannot see, e.g. reaching blocked system/file APIs indirectly.\n\nAsk the user to review the code and confirm. If they approve,\nre-invoke execute_ruby with confirm_risky: true.\n\n\nThe model can’t wave this through on its own. A human reviews the code, and only then does the call run with confirm_risky: true. It puts a person in the loop exactly where the static analysis runs out of road.\n\nBeing honest about the boundary\n\nI want to be direct about the limits, because a security tool that oversells itself is worse than one that doesn’t. These controls are layered defense, not hard isolation. The tool still executes real Ruby with full application access, so a determined bypass is possible; DDL and writes on non-default connections can escape the rollback; there are no per-process CPU or memory caps beyond the timeout.\n\nIf you need stronger guarantees, the right moves are outside the Ruby layer: run the server against a database user with read-only grants, and/or run it inside an OS-level sandbox (a container, sandbox-exec, seccomp). The SECURITY.md in the repository now documents both the controls and these limitations so you can make an informed call.\n\nManager-agnostic Ruby resolution\n\nSeparate from the sandbox, 1.6.0 fixes a long-standing annoyance for anyone whose Ruby isn’t the system default. Tools that shell out to bin/rails (execute_ruby, get_schema, and the introspection halves of analyze_models and analyze_controller_views) were exporting the rbenv-only RBENV_VERSION and running a login shell. On macOS, path_helper then reshuffled PATH so bin/rails booted under system Ruby and failed.\n\nThe runner now prepends the active version manager’s shims directory to PATH (mise, asdf, or rbenv, honoring MISE_DATA_DIR, XDG_DATA_HOME, ASDF_DATA_DIR, and RBENV_ROOT) and uses a non-login shell so the project’s Ruby survives. rvm, which has no shims, is sourced when present. The version comes from your project’s .ruby-version, .tool-versions, or .mise.toml, so different projects can use different Rubies with no extra configuration.\n\nWhile I was in there, the analyzer path stopped swallowing errors with 2>/dev/null, so a Rails boot failure now surfaces the real message instead of a blank “Error executing Rails command.”\n\nNamespaced models resolve properly\n\nanalyze_models could report a module-namespaced model as “not found” depending on how you referred to it. It now resolves from every input form: Namespace::Model, the path namespace/model, the flattened NamespaceModel, and the bare leaf Model. That holds independent of your app’s custom inflections. The introspection runner also derives the constant from the resolved file instead of interpolating raw input, which removes an injection surface in the generated scripts along the way.\n\nBreaking change: Ruby 3.2 is dropped\n\nThe minimum supported Ruby is now 3.3 (required_ruby_version >= 3.3.0), and CI tests 3.3 and 3.4. The dependency updates in this release pull in transitive gems that require 3.3, so this was forced rather than chosen. If you’re on 3.2, upgrade your Ruby before updating the gem.\n\nSecurity and dependencies\n\nThe dependency bump is also a security update. Upgrading to Puma 8.0.2 clears CVE-2026-47736 and CVE-2026-47737 (both HIGH, covering PROXY Protocol v1 remote memory exhaustion and repeated-header handling), and the lockfile refresh clears a concurrent-ruby advisory. bundler-audit reports clean.\n\nUpgrading\n\ngem update rails-mcp-server\n\n\nMake sure you’re on Ruby 3.3 or newer first. If you use Claude Desktop, restart it to pick up the new version; the binary path in your configuration doesn’t change.\n\nFor new installations:\n\ngem install rails-mcp-server\nrails-mcp-config\n\n\nWhat’s Next\n\nGiving a model a Ruby runtime inside your app is a sharp tool. This release makes the edges more honest: fewer accidental cuts, and a clear label on what the guard does and doesn’t cover. The durable next step is real isolation: read-only database roles and OS-level sandboxing as first-class, documented setups rather than footnotes.\n\nIf you find a gap, the issue tracker is open, and security reports have a private channel through the repository’s Security tab. Pull requests are welcome.\n\nLinks\n\n\n GitHub Repository\n RubyGems\n Documentation\n Security Policy\n AI Agent Guide" }, { "id": "blog-2026-07-maquina-components-0-6-0-themeable-beyond-color", "title": "Maquina Components 0.6.0: Themeable Beyond Color", "collection": { "label": "posts", "name": "Blog" }, "categories": "Announcements, Release", "tags": "", "url": "/blog/2026/07/maquina-components-0-6-0-themeable-beyond-color/", "content": "Maquina Components, the server-rendered UI component library for Rails and Tailwind, is out in 0.6.0. It adds a token layer for radius, elevation, focus rings and font weight, moves all engine CSS into @layer components, and fixes a focus ring that had been dead on six of seven button variants. The release is deliberately breaking: seven changes, and the first one fails silently in every existing application.\n\nUntil now the library was themeable in color and in nothing else. Radius, elevation, focus rings, font weight and hover states were written directly into the stylesheets, so changing any of them meant overriding selectors rather than declaring values. Theming one real application against the 0.5 releases took roughly 1,700 lines of override CSS, and most of that was not expressing a design. It was reaching past the cascade.\n\nWhy tokens alone would not have fixed it\n\nEvery engine rule was unlayered and carried specificity from its variant and state qualifiers, so appearance and structure shared one flat cascade. A theme could not reach the appearance without also being able to break the structure, and the engine defended against that by being hard to override at all. Adding tokens on top of that arrangement would have changed nothing.\n\nThree things changed together. There is now a token layer: --control-radius, --surface-radius, --focus-ring-width, --elevation-raised, --label-weight and the rest, declared in @theme and read from every rule that used to hardcode a value. All twenty stylesheets moved into @layer components, which is what lets a caller’s Tailwind utilities apply. And specificity is flat now: every rule sits at 0,1,0, with variants and states in :where(), so a theme’s [data-component=\"button\"] means every button, which it previously did not.\n\nWhich gives a contract worth stating plainly: a theme changes values, not selectors. Reach for a selector only when you want to change a component’s shape, like a different padding rhythm or a variant the engine does not ship.\n\nA flat theme is six lines:\n\n:root {\n --elevation-control: none;\n --elevation-raised: none;\n --elevation-overlay: none;\n --control-radius: 0.25rem;\n --surface-radius: 0.25rem;\n}\n\n\nThe theming guide has the full token reference. Every component demo on the documentation site now carries a shape toggle in its chrome; flip it to brutal or soft and the whole library changes shape from token declarations alone.\n\nThe focus ring was dead\n\n[data-component=\"button\"]:focus-visible was declared before the variant rules, at the same specificity. Each variant then re-declared box-shadow for its own elevation, and later-at-equal-specificity wins. The focus ring was silently overwritten on every variant that set a shadow.\n\nOn the demo page, two of the sixteen buttons showed a ring, and both were destructive — the only variant that happened to re-declare its own focus rule after its variant rule. Primary, secondary, outline, ghost and link had no visible keyboard focus at all. That is a WCAG 2.4.7 failure. It shipped, and it was invisible in code review because every rule involved looked correct on its own.\n\nFocus is now an outline rather than a box-shadow. Outlines do not participate in box-shadow, so a variant’s elevation can no longer overwrite a ring. They survive forced-colors mode, and they are not clipped by overflow: hidden ancestors, which had been quietly cutting rings off inside the sidebar and drawer. Every focusable button rings now, and a test asserts that state rules follow variant rules in every stylesheet, so the ordering that caused this cannot come back.\n\nYour utility classes now win\n\ncss_classes: is the documented way to adjust one instance of a component, and it has been partly a lie. Because engine rules were unlayered, they beat any Tailwind utility passed through them:\n\n\n [data-component=\"input\"] set w-full, so any w-* you passed was dead.\n [data-form-part=\"actions\"] set display: flex, so sm:hidden did nothing.\n [data-component=\"form\"] set display: grid. Pass sm:flex-row and it silently stopped being a row.\n\n\nThese are layout failures, not restyles, and they failed quietly, which is why the workaround was always a wrapper element. With the engine in @layer components, utilities win. Measured: an input with a width utility goes from 448px to 137px.\n\nWorth searching your views for css_classes: after upgrading. Anything you passed as decoration and never saw is about to take effect.\n\nBefore you upgrade: run the scanner\n\nThe release ships a scanner. Run it inside your application:\n\nbin/rails maquina:doctor\n\n\nIt reads your CSS, views and JavaScript and prints file:line for every pattern this release changes, grouped by severity: the unlayered * rule, component overrides the token layer makes redundant, restated SVG data URIs, [data-active] presence selectors, .dark twins. It never edits anything and always exits 0.\n\nBreaking changes\n\nSeven. The first affects every existing application and fails silently.\n\n\n \n The preflight shim in your theme.css now outranks the engine. Your installed theme.css carries this rule:\n\n * {\n border-color: var(--color-border);\n}\n \n\n Unlayered CSS outranks every layer at any specificity. Now that engine rules live in @layer components, that one universal rule wins over the tinted borders on all alert and toast variants: a destructive alert renders with a plain grey border where 0.5.1 painted a red one. The generator template is fixed, but the rule lives in your file. Wrap it:\n\n @layer base {\n * {\n border-color: var(--color-border);\n }\n}\n \n \n Utility classes now win. Anything passed through css_classes: that was previously overridden by an engine rule will take effect.\n Radius and elevation defaults normalize. Card goes 12px to 8px, popovers 6px to 8px, and four shadow-lg sites collapse to --elevation-overlay.\n Focus rings become outlines, and form fields stop ringing on mouse click.\n merge_component_data precedence narrows to identity keys.\n Surfaces that sit above the page stop painting --background.\n Tinted badges lose a stray hairline the shim had been forcing onto them.\n\n\nEvery one of them is a value, so the upgrading guide closes with an appendix that restores the 0.5.1 look with a single token block.\n\nUpgrading\n\nbundle update maquina-components\n\n\nThen re-run the installer to append the new shape and state tokens to your theme. It is idempotent and will not touch your palette:\n\nbin/rails generate maquina_components:install\n\n\nThen read the upgrading guide.\n\nAlso in this release\n\nNew:\n\n\n Drawer gained title, description, section and separator partials. The first two had been styled by CSS since the beginning with nothing to emit them, so the documentation told you to hand-write <h2 class=\"text-lg font-semibold\">.\n Sidebar gained menu badges, menu actions and group actions on the same footing.\n There is a label partial now, which makes the required-field indicator reachable.\n\n\nFixed:\n\n\n dropdown_menu_simple raised NoMethodError and combobox_simple rendered an empty popover. Both had zero call sites in the repository, which is exactly why they shipped broken.\n Two components were building correct data attributes and then discarding them, so [data-variant=\"bordered\"] on a table was unreachable.\n\n\nWhat this reinforced\n\nEvery bug in this release looked correct in the stylesheet. The focus ring rule was right there in the file, and the dead table variant was right there in the partial. What caught them was asserting on compiled output and computed styles: does this token reach the browser, does this rule come after that one, does this element actually have a ring. The tests that came out of the audit assert those things.\n\nThe quieter finding was a set of styled hooks that turned out to be emitted by nothing at all — CSS that read as supported API and matched no markup. That is worse than a missing feature, because it looks finished. Six became real partials here and two were deleted.\n\nDocumentation\n\n\n Theming\n Upgrading\n Component documentation\n\n\nSource\n\n\n Maquina Components\n Full Changelog\n Gem on RubyGems" }, { "id": "blog-2026-07-introducing-nexo", "title": "Introducing Nexo: the harness for Ruby agents", "collection": { "label": "posts", "name": "Blog" }, "categories": "Announcements, Product, AI Tools", "tags": "", "url": "/blog/2026/07/introducing-nexo/", "content": "Ruby has most of the pieces you need to build an AI agent already, and they’re good.\nruby_llm gives you a provider-neutral chat loop and tool calling that’s cleaner than\nmost language SDKs out there, one API across a dozen providers instead of a new SDK\nper vendor. ruby_llm-skills gives you SKILL.md loading. ruby_llm-mcp gives you MCP\nservers. ruby_llm-schema gives you structured output. What none of them give you is\na front door, one place where those pieces snap together with defaults you don’t have\nto think about twice.\n\nBuilding an agent on top of these gems means wiring the same sandbox and permissions\nboilerplate into every new project, by hand, every time. Nexo exists to stop that.\nIt doesn’t rebuild the tool-call loop, skill loading, MCP, or structured output; those\nalready exist, they’re well built, and duplicating them would just make Nexo worse at\nthe things ruby_llm and its ecosystem already do well. Nexo sits on top of them\ninstead. Two things were missing\nfrom the ecosystem, and those are the two things Nexo adds: a sandbox and permissions\nseam, and a real job primitive called WorkflowRun.\n\nAgent = Model + Harness\n\nA model on its own forgets everything the moment it finishes a response. Turning “a\nmodel that replies” into “an agent that does work” takes tools, a place for those\ntools to act, a policy for what they’re allowed to do, and a way to track a job from\nstart to finish. That collection of things is the harness. Ruby has all of it, but\nscattered across gems with different DSLs, and wiring them by hand is the first thing\nevery new agent project reinvents.\n\nNexo is the nexus: the connective tissue between a model and everything else an agent\nneeds, and between the fragmented RubyLLM-ecosystem gems and one coherent whole.\n\nrequire \"nexo\"\n\nclass CodeReviewer < Nexo::Agent\n model ENV.fetch(\"NEXO_MODEL\") # any ruby_llm model, no vendor default\n sandbox :local\n permissions :read_only\n\n instructions \"You are a careful code reviewer. Read files and report issues. Do not write files.\"\nend\n\nCodeReviewer.new(cwd: \"/path/to/repo\").prompt(\"Review the auth module\")\n\n\nWhat Nexo adds: a sandbox and permissions seam\n\nAn agent’s tools have to act somewhere, and something has to decide what they’re\nallowed to do there. Nexo splits that into two questions, where (the sandbox) and\nwhat (the permission mode), and answers both safely by default: :virtual sandbox,\n:read_only permissions. An untrusted model gets zero host access until you opt into\nmore, explicitly, in your own code.\n\n\n Virtual runs in memory with zero host access. #shell raises\nNotImplementedError on purpose, that’s the safety property, not a bug in waiting.\n Local gives you the host filesystem and shell for trusted dev and CI work.\nEvery path is guarded to stay inside cwd, and the shell only sees PATH, HOME,\nand LANG. It never sees your full environment.\n Container runs tools inside a throwaway Docker or Apple container sandbox,\nhardened by default (no network, dropped capabilities, read-only rootfs), with every\nhardening an explicit opt-out. Full flag reference is in the\nsandboxes doc.\n Remote is a four-method contract, read, write, exec, close, that you\nadapt to whatever provider you’re already using. Nexo ships zero vendor code here;\nswap the injected object and you’ve swapped providers.\n\n\nA write under :read_only, a shell call on Virtual, any denied action comes back\nas { error: ... } and the loop keeps running. The model can read that and adjust; a\npermission denial isn’t a reason to crash the process. A path that tries to escape its\nsandbox is a different kind of failure, and it raises SecurityError instead.\n\nWhat Nexo adds: WorkflowRun\n\nAn agent accumulates context, it’s a conversation that keeps going. A lot of real\nwork isn’t that. It’s a finite task that starts, does something, and finishes with a\nresult you can check on later, sometimes from a different process entirely. Nothing\nin the ecosystem covered that cleanly, so Nexo adds Workflow.\n\nA workflow can drive an agent too, so the two primitives Nexo owns fold into one\nrecipe: stage inputs into the run’s sandbox, run the agent against them, capture the\noutput. The agent class macro names the agent, and run_agent runs it bound to the\nrun’s own sandbox:\n\nclass Summarizer < Nexo::Agent\n model ENV.fetch(\"NEXO_MODEL\") # any ruby_llm model, no vendor default\n\n instructions \"Summarize the given text in two plain sentences. No commentary.\"\nend\n\nclass SummarizeDocument < Nexo::Workflow\n agent Summarizer\n\n def call(payload)\n emit(:started, doc_id: payload[:doc_id])\n response = run_agent(\"Summarize this document:\\n\\n#{payload[:text]}\")\n emit(:summarized, length: response.content.length)\n { summary: response.content }\n end\nend\n\nrun = SummarizeDocument.run(doc_id: 123, text: \"Long text...\")\nrun.id # => \"0191d6b2-...\" (UUID v7, time-ordered)\nrun.status # => \"done\"\nrun.result # => { \"summary\" => \"The document covers ...\" }\n\n\nEvery run gets a stable id, a status, a payload, a result, and an ordered event log\nyou can replay with Nexo::Workflow.logs(run.id) or rake nexo:logs[id]. The\nagent’s tool calls and its final response land in that same log alongside the\nworkflow’s own :started/:summarized events, so a driven run reads as one story\ninstead of two logs stitched together by hand. Outside Rails it all records to memory\nand just works offline, no database required; inside Rails, install the migration and\nthe same code persists to a nexo_workflow_runs table instead, with no other change.\n\nA workflow that raises is recorded as failed and the exception still propagates to\nyour caller, the opposite of a tool failure, which returns { error: ... } and never\ninterrupts the agent loop. Jobs that need to pause and pick back up later, waiting on\na human approval or a slow external process, get the same lifecycle extended with\ncheckpoints, suspend!, and resume, covered in the\ndurable workflows doc.\n\nProvider-neutral, on purpose\n\nThe only hard dependency is ruby_llm. No hardcoded default model, no vendor SDK\ncalled directly anywhere in the core loop; every example in the docs pulls the model\nfrom ENV.fetch(\"NEXO_MODEL\"), just as happy pointing at a local Ollama model as a\nhosted one. ruby_llm-skills, ruby_llm-mcp, and an optional Anthropic-oriented\nautonomous loop are all soft dependencies, required lazily, and they raise a clear\nNexo::MissingDependencyError with install instructions if you haven’t added them.\nYou only pay for what you use.\n\nWhere it stands today\n\nNexo is early and the API isn’t stable yet. The Apple container runtime parity\ntable\nin the docs says so plainly: Docker is the verified path today, and Apple’s CLI flags\nare encoded from the reference mapping but not yet confirmed against a live daemon.\nThe caveat is published rather than papered over.\n\nWhat’s shipped: the sandbox and permissions seam across Virtual, Local,\nContainer, and Remote; the Workflow/WorkflowRun lifecycle with staging,\nartifacts, and durable checkpoints; skills, MCP behind a fail-closed gate, a web\nfetch tool with an SSRF guard, sessions, and a Rails engine with generators for the\nconventional app/agents / app/workflows / app/skills layout.\n\ngem \"nexo_ai\"\n\n\nThe gem publishes as nexo_ai; everything in the code lives under Nexo::.\n\nNone of this exists without the RubyLLM ecosystem underneath it. Nexo was worth\nbuilding because that foundation, ruby_llm and\neverything built around it, is solid.\n\nThe Nexo documentation has the full index, sandboxes,\npermissions, tools, loop backends, workflows, durable workflows, skills, MCP, web,\nsessions, Rails, and concurrency. If you’re already building on ruby_llm and you’ve\nwritten this same sandbox-and-permissions setup more than once, this is for you.\n\nRepo: github.com/maquina-app/nexo" }, { "id": "blog-2026-07-maquina-components-0-5-0-drawer-and-scaffold-templates", "title": "Maquina Components 0.5.0: Drawer and Scaffold Templates", "collection": { "label": "posts", "name": "Blog" }, "categories": "Announcements, Release", "tags": "", "url": "/blog/2026/07/maquina-components-0-5-0-drawer-and-scaffold-templates/", "content": "This release adds a Turbo-aware Drawer component, a scaffold_templates generator that makes rails generate scaffold produce styled views, and engine helpers included in the generated helper module. It’s also a first for the project: most of it came from contributors.\n\nDrawer\n\nA slide-out panel with an overlay backdrop, built from sub-partials the same way Card and Sidebar are. A provider owns the state; header, content, and footer structure the panel; a trigger toggles it and a close dismisses it.\n\n<%= render \"components/drawer/provider\", default_open: drawer_open? do %>\n <%= render \"components/drawer\", state: drawer_state do %>\n <%= render \"components/drawer/header\" do %>\n <h2 class=\"text-lg font-semibold\">Filters</h2>\n <%= render \"components/drawer/close\" %>\n <% end %>\n\n <%= render \"components/drawer/content\" do %>\n <!-- Panel body -->\n <% end %>\n\n <%= render \"components/drawer/footer\" do %>\n <!-- Apply / Reset -->\n <% end %>\n <% end %>\n<% end %>\n\n\nDrop a trigger anywhere on the page as a plain toggle button:\n\n<%= render \"components/drawer/trigger\" %>\n\n\nThe drawer opens from the right by default; pass side: :left to open from the other edge.\n\nFeatures: compound structure (provider, header, content, footer, trigger, close), left or right side with an overlay backdrop, a configurable Cmd/Ctrl+D shortcut, cookie-based state persistence, and full Turbo Drive and Morph compatibility.\n\nSurviving Turbo\n\nThe reason a drawer is more than a CSS transition is the lifecycle around it. The controller handles the three places Turbo usually breaks a stateful component:\n\nCache teardown. Before Turbo snapshots the page for its cache, the drawer closes and hides its backdrop. A restored snapshot never comes back frozen mid-transition.\n\nMorph awareness. With turbo_refresh_method_tag :morph, the server re-renders the page in its default state—it doesn’t know the drawer was open. The controller re-reads its cookie on morph and reasserts the correct state—the same fix the sidebar got in 0.4.0.\n\nPersistence. State lives in a cookie, so the drawer holds across full page loads and Turbo navigations alike.\n\nThat state is exposed through three helpers, so your server-rendered markup and the client agree on the first paint:\n\ndrawer_state # => :open or :closed\ndrawer_open? # => true / false\ndrawer_closed? # => true / false\n\n\nPassing default_open: drawer_open? into the provider and state: drawer_state into the drawer, as in the usage above, is what closes the loop between the cookie and the initial render.\n\nScaffold Templates\n\nHaving a component library is one thing; getting your generated code to use it is another. The new scaffold_templates generator closes that gap.\n\nbin/rails generate maquina_components:scaffold_templates\n\n\nIt copies a set of ERB scaffold templates—index, show, new, edit, _form, and the record partial—into lib/templates/erb/scaffold/. Rails has always let you override its generator templates from that path; what was missing was a set that renders with the component library. Now they ship with the gem.\n\nFrom then on, the standard scaffold generator produces styled views out of the box:\n\nbin/rails generate scaffold Post title:string body:text\n\n\nYou get tables, buttons, and form fields built with Maquina Components instead of Rails’ default markup—before writing any view code. Because the templates are copied into your app, they’re yours to edit afterward.\n\nEngine Helpers in the Generated Module\n\nA quieter change in the same direction. The generated MaquinaComponentsHelper now includes IconsHelper, SidebarHelper, and ToastHelper. Helpers like icon_for, sidebar_open?, and toast_flash_messages are available in host-app views without an extra include in ApplicationHelper. It’s a one-line diff in the template that removes a papercut every new install used to hit—reaching for icon_for and getting a NoMethodError because the module wasn’t wired up yet.\n\nIcon Class Handling\n\nThe one bug fix this release tightens apply_icon_options. It now guards against nil and non-string class values, HTML-escapes the class before it reaches the markup, and injects a class attribute onto <svg> elements that didn’t already have one. Small, but exactly the kind of edge case that only surfaces once icons are being passed around inside real templates.\n\nContributors\n\nThis release was built mostly by two people who aren’t me:\n\n\n @GregorioNeto — the Drawer component (#21) and the icon class handling fix (#17)\n @JuanVqz — the scaffold_templates generator (#20) and the engine helper modules in the generated helper (#19)\n\n\nThank you both.\n\nUpgrading\n\nbundle update maquina_components\n\n\nThen, when you want styled scaffolds, install the templates:\n\nbin/rails generate maquina_components:scaffold_templates\n\n\nThere are no breaking changes in this release.\n\nWhat This Reinforced\n\nThree of the four changes pull the same way: they make the gem lean on Rails instead of sitting next to it. The scaffold templates go through Rails’ own generator override path. Helpers are included the way any Rails helper is. And the Drawer keeps its state in a cookie and reacts to Turbo’s morph, the way the sidebar already does. Less to learn, fewer seams to trip over.\n\nThe other thing worth saying is that I reviewed this release more than I wrote it. Gregorio and Juan built the components; I merged them. That says more about where the project is than any one feature does.\n\nDocumentation\n\n\n Drawer\n Component documentation\n\n\nSource\n\n\n Maquina Components\n Full Changelog\n Gem on RubyGems" }, { "id": "blog-2026-07-introducing-fragua", "title": "Introducing Fragua: the harness already composed for Rails", "collection": { "label": "posts", "name": "Blog" }, "categories": "Announcements, Product", "tags": "", "url": "/blog/2026/07/introducing-fragua/", "content": "Fragua is live in private beta. It’s an AI agent orchestrator built specifically for Rails developers, and it’s the newest product under the Maquina umbrella.\n\nFragua is Spanish for forge — the place where raw material becomes a finished tool under heat and pressure. That’s the intent: take a project from a one-line idea to a shipped pull request, running through research, planning, spec-driven development, and execution.\n\nWhy we built it\n\nCoding agents are good at writing code and bad at remembering why. Every new session tends to start from zero — the spec gets re-pasted, the data model gets re-explained, and the agent has no memory of a decision it helped make yesterday.\n\nWhat Fragua actually sells isn’t an AI that writes code. It’s durable context and a disciplined workflow. Each phase — brief, research, plan, spec — produces a structured artifact that persists in the workspace and becomes context for the next agent. By the time the execution agent runs, it’s not guessing; it’s reading the plan, the technical guide, and the spec that already exist.\n\nThe shape of it\n\nFoundation → Spec → Execution → Pull Request\n\n\nOnly Foundation (a working copy of your codebase) and an accepted Spec are required. Everything else — Product Brief, Research, MVP Plan, Brand Guide, Technical Guide — is optional enrichment that makes the execution agent sharper without ever gating it. Execution itself starts on its own: a sweep every ~2 minutes picks up accepted specs and approved issue fixes, so there’s no button for “start the build.”\n\nUnder the hood, Fragua splits the work across two planes — a web app that plans and directs, and your own machine that actually runs the agent and pushes the branch. We walk through that split, plus a live tour of the web app, in the video below.\n\nRails-first, not Rails-only\n\nEvery new app Fragua scaffolds is Rails 8.1 with Hotwire — Stimulus controllers, Minitest fixtures, Turbo Streams, the full set of conventions the agents already know how to read and write. That part is deliberate and non-negotiable: it’s what lets Fragua write code that looks like it belongs in your app instead of generic output.\n\nWhere you’re pointing Fragua at a codebase that already exists, the door is wider. Foundation and the Spec and Issue agents read your repo rather than dictate its stack, so brownfield projects outside Rails are something we’re genuinely open to exploring — if that’s your situation, say so in your access request and tell us what you’re working in.\n\nWhat’s actually different day to day\n\n\n Durable context — nothing evaporates between sessions; later phases build on everything earlier ones produced.\n BYOK — tokens bill straight to your own Anthropic account. Fragua never proxies the call, never stores the key, can’t see your bill.\n Full observability — a live run timeline, per-turn cost broken down by phase and rolled up by workspace and month, and a durable audit trail. Metering, not gating — there are no spend caps to trip.\n Institutional memory — the Knowledge Base, every phase artifact, and the full run history live in the workspace, not in one person’s head. When someone hands off or leaves, the context stays.\n Your host, your code — agents run on your machine, against your repo, with your own git and GitHub credentials. Nothing you own leaves it.\n Claude Code today, Codex coming — the agent runtime is Claude Code right now, with Codex support coming shortly.\n Sandboxed if you want it — run the agent directly on your host, or isolate it inside Docker or a macOS Container instead. Setup is in the CLI guide.\n\n\nWatch the two-plane split in action\n\n\n\n\n\n\n\n\n\nWhere things stand\n\nFragua is in private beta, by invitation. A human reads every request before a seat opens — there’s no schedule, seats open based on fit rather than a calendar.\n\n\n \n \n Plan\n Price\n Includes\n \n \n \n \n Solo\n $19/month\n 1 user, unlimited workspaces, full cost dashboard, per-feature worktrees, BYOK\n \n \n Team\n $69/month, flat\n 2–10 users, shared workspaces, admin & member roles, audit trail across every run, priority support, BYOK\n \n \n Enterprise\n Contact for quote\n 11+ users, unlimited workspaces, on-premises deployment, dedicated support, BYOK\n \n \n\n\nAll plans are free for the duration of the beta. Pricing begins 30 days after public launch, with email notice ahead of time. A read-only viewer role is on the roadmap — not shipped yet.\n\nNeed more than 10 seats, or want it on-premises? Email mario@fragua.app and we’ll work out the details directly.\n\nIf you’re building on Rails 8.x and Hotwire — solo, freelance, a small consultancy, or a growing product team — request access at fragua.app/#access. Working in something else on an existing codebase? Tell us in the request; we’d like to hear about it.\n\nThe full docs live at fragua.app, including dedicated pages on observability, teams, and trust.\n\nAgents that respect the craft. Your host, your keys, your repo.\n\n\n\nFragua is built by Maquina." }, { "id": "blog-2026-06-recuerd0-mcp-and-redesign", "title": "recuerd0 Now Has an MCP Server — and a Better Look", "collection": { "label": "posts", "name": "Blog" }, "categories": "Announcements, Product", "tags": "", "url": "/blog/2026/06/recuerd0-mcp-and-redesign/", "content": "recuerd0 has always had multiple ways to access your knowledge: a REST API, a CLI for terminal workflows, and an agents guide for hooking coding agents directly into your memories. The access model has been there from the start — Bearer token, call the endpoints, get your context back.\n\nMCP adds another path. Instead of configuring the connection upfront, any MCP client can reach your memories on demand — mid-conversation, mid-task, whenever the context is needed — without a token to copy or a config file to edit. Building that properly meant adding an OAuth 2.1 authorization server, a consent screen, and connected application management — surface area that needed to feel right in the browser.\n\nWhile that work was underway, the editing experience got the same attention. The textarea that had always been good enough stopped feeling good enough when you’re managing a knowledge base you actually rely on. The fix was already sitting in plain sight: 37signals shipped House MD with Writebook, and it fit recuerd0’s requirements almost exactly.\n\nBoth changes landed at the same time, alongside a UI pass that tightened the information hierarchy across the workspace and memory views.\n\n\n\nMCP support\n\nrecuerd0 runs a remote MCP server at POST /mcp — Streamable HTTP, JSON-RPC 2.0, compatible with any client that speaks the protocol.\n\nThe connection is consent-based. The client registers itself with Dynamic Client Registration, you approve a short permission screen in the browser, and that’s it. No token to generate, no config to edit. Approve once per client; disconnect any time from your profile.\n\n\n\nThe authorization server is built directly into the Rails app — no external OAuth service, no added dependencies. PKCE instead of shared secrets. Access tokens last one hour; refresh tokens rotate silently, so a connected client stays connected without interrupting you.\n\nSix tools\n\nThe server exposes six tools across three read and three write operations, all scoped to your account’s workspaces:\n\n\n \n \n Tool\n Type\n What it does\n \n \n \n \n list_workspaces\n read\n List the workspaces in your account\n \n \n list_memories\n read\n List memories; optional FTS5 query and category filter\n \n \n read_memory\n read\n Read the full content of one memory\n \n \n create_memory\n write\n Create a memory, with optional tags\n \n \n update_memory\n write\n Update title, content, category, or tags\n \n \n create_version\n write\n Append an immutable version, preserving history\n \n \n\n\nRead tools require the memories:read scope. Write tools — including create_version — require memories:write. Scopes map to the existing read_only / full_access permission tiers, so there’s no parallel auth system to reason about.\n\ncreate_version is the one worth pausing on. Every memory in recuerd0 already has a versioning model — you can snapshot a decision or convention as it evolves and keep the full history. The MCP tool exposes that directly. If a client updates a memory and you want to preserve what was there before, it can append a version rather than overwrite.\n\nConnect it\n\nThe MCP endpoint is at your instance URL:\n\nhttps://recuerd0.ai/mcp\n\n\nFor self-hosted instances, replace the domain with your own. Any MCP client that supports custom connectors — Claude Desktop, Claude.ai, Cursor, and others — can connect by pointing at that URL. The OAuth handshake runs itself.\n\nFull documentation is at recuerd0.ai/mcp.\n\n\n\nThe editor\n\nrecuerd0 stores knowledge as markdown. The editing experience should match that — not fight it. A plain textarea works, but it asks you to hold the syntax in your head while you write. After using it long enough, the friction starts to matter.\n\n\n\nThe new editor is based on House MD — the markdown editor 37signals built for Writebook. Minimal toolbar: bold, italic, quote, code, link, bullet list, numbered list. It writes markdown, not HTML. No mode switching, no preview tab — what you see while editing stays close to what the rendered output looks like.\n\nOne thing worth being explicit about: everything that reads your memories — the API, the CLI, MCP tools — still gets raw markdown. The editor doesn’t change that contract.\n\n\n\nThe UI\n\nrecuerd0 is built on Maquina Components — a Rails component library with cards, badges, buttons, dialogs, and form elements. The component foundation was already there; this pass refined how those pieces are composed and extended them with patterns specific to what recuerd0 actually is.\n\n\n\nThe workspace and memory views got a more deliberate information hierarchy — metadata where you need it, actions reachable on touch and keyboard, pinned items in their own labeled section rather than blended into the list. The color system was tightened so the app reads consistently across every page, and view preferences (list vs grid, cards vs compact) now persist between sessions.\n\nThe workspace index and workspace show pages have been updated. Memory show, the editor page, and settings are next.\n\n\n\nThe MCP server is live. Full documentation at recuerd0.ai/mcp.\n\nrecuerd0 is free to self-host. Managed hosting is $15/month.\n\nRelated reading\n\n\n Recuerd0 Now Reads Like a Filesystem — grep, glob, and ranged read on your memories.\n Announcing Recuerd0 — the original launch and the problem we set out to solve.\n Recuerd0 source code is now available — how to self-host under OSAASY." }, { "id": "blog-2026-04-recuerd0-api-release", "title": "Recuerd0 Now Reads Like a Filesystem", "collection": { "label": "posts", "name": "Blog" }, "categories": "Announcements, Product", "tags": "", "url": "/blog/2026/04/recuerd0-api-release/", "content": "The new Recuerd0 API release teaches the memory store to behave like a filesystem — so AI agents already fluent in grep, glob, and read need no new vocabulary.\n\n\n\nRecuerd0 is the persistent memory store for AI coding agents built by Maquina, and this release reshapes how agents read from it. When an AI agent reaches into Recuerd0 for context, the bottleneck has never been storage. It has been how much the agent has to pull back to get to the one fact it needs. A 2,000-line transcript should not have to fit within the agent’s context window to answer “did we decide to use Postgres?” The new release fixes that — and a handful of other long-standing rough edges — by giving the API the same primitives every coding agent already knows: glob, grep, and ranged read.\n\nHere is what is new.\n\nFile-tool API: glob, grep, and ranged read on memories\n\nThe biggest shift in this release is conceptual. Memories are no longer monolithic blobs that you fetch whole. They are addressable like files.\n\nGlob. The browse and list endpoints accept a title glob pattern. * matches any sequence of characters, ? matches a single character. Combined with tags, source, category, and workspace_id, the agent can narrow a thousand memories down to the dozen worth looking at without reading any bodies.\n\nGET /memories.json?title=Meeting*&tags=design,api&category=decision\n\n\nRanged read. GET /workspaces/:id/memories/:id.json now accepts line_start and line_end (1-based, inclusive). The response always echoes total_lines, so the client knows how much memory is available and can compute a tail window in a single follow-up call.\n\nGET /workspaces/1/memories/42.json?line_start=40&line_end=55\n\n\nThere is no head= or tail= parameter — and that is deliberate. line_start=1&line_end=20 is “head 20”; line_start=(total_lines - 19)&line_end=total_lines is “tail 20”. One verb covers both, and the client never has to learn a parallel vocabulary for the same operation.\n\nGrep with line numbers. ?mode=grep&q=<query> switches the same endpoint into a grep response. Instead of returning the body, it returns an array of matches:\n\n{\n \"content\": {\n \"total_lines\": 2174,\n \"matches\": [\n {\n \"line_number\": 1247,\n \"line\": \"Decided: Postgres for the analytics warehouse, SQLite for everything else.\",\n \"context_before\": [\"## Database choice\"],\n \"context_after\": [\"Reason: ops simplicity outweighs the JOIN ceiling for our scale.\"]\n }\n ]\n }\n}\n\n\nOptional context, before, and after parameters control how many surrounding lines to return — capped at 10 each, like grep -C, -B, and -A. The full-text search endpoint (/search.json) supports the same grep mode for cross-memory queries.\n\nThe two-step recipe the agent should reach for: first, use grep to locate the line numbers; then issue a follow-up line_start/line_end call to fetch only the surrounding window. A 2,000-line memory becomes a 20-line answer.\n\nMemory categories\n\nEvery memory now carries a category: decision, discovery, preference, or general (the default). It is a small thing, but it changes how an agent reasons about what it is reading. A decision is load-bearing — something the team chose and is sticking with. A discovery is a fact about the world. A preference is taste. The agent does not have to infer the difference from prose; it is right there in the metadata, filterable from any list endpoint.\n\nGET /memories.json?category=decision&sort=updated_at\n\n\nCross-workspace memory links\n\nMemories can now reference each other across workspaces with first-class “see also” links. The Rails decision in your Backend workspace can point to the deployment write-up in Infrastructure without copying anything. Each memory’s response includes a links_count so the agent knows there is more context one hop away, and dedicated endpoints under /memories/:id/links let it list and traverse them.\n\nThis is the connective tissue for context that lives in more than one place — which, in practice, is most context worth keeping.\n\nWorkspace wake-up endpoint\n\nA new endpoint, GET /workspaces/:id/context.json, returns a compact “wake-up” payload for an agent starting a fresh session: workspace metadata, recent memory titles, and the highlights an agent should know about before it does anything else. It is the answer to “you are picking up where you left off, here is the room you just walked into.”\n\nPair it with a Claude Code session-start hook and a new conversation begins with the right context already loaded — no manual recuerd0 memory list dance, no asking the user to repeat themselves.\n\nHTTP caching across the API\n\nAll read endpoints now emit ETag and Last-Modified headers and respect conditional requests. A client that sends If-None-Match for a memory it already has receives a 304 Not Modified response with an empty body. For agents that re-fetch the same workspace several times in a session, this is a meaningful drop in tokens shipped over the wire — and a meaningful drop in load on the database.\n\nGrep and ranged-read responses are correctly bypassed by the cache, since they are derived from query parameters that change with each call.\n\nCLI: recuerd0 memory read\n\nThe recuerd0-cli gains a memory read command group that wraps the new endpoints so a human (or a terminal-bound agent) can use them without hand-crafting URLs:\n\nrecuerd0 memory read head 42 --lines 20\nrecuerd0 memory read tail 42 --lines 20\nrecuerd0 memory read lines 42 --start 100 --end 140\nrecuerd0 memory read grep 42 \"Postgres\" --context 2 --pretty\n\n\nIn --pretty mode, the grep subcommand emits a breadcrumb for each hit, suggesting the exact memory read lines, followed by a call to fetch a window around it. The two-step pattern is right there in the output — no thinking required.\n\nAgent guidance baked in\n\nThe Claude Code recuerd0 agent skill now ships guidance for when to use the new primitives, not just how. The dedup-before-write protocol prefers memory read grep over memory show for large candidates. The workflow guidelines tell the agent: when total_lines > ~200, grep first and fetch a window — reserve full reads for memories you genuinely need in their entirety.\n\nThe point of teaching these patterns to the agent is the same as the point of adding them to the API in the first place: make the cheap thing the obvious thing.\n\nDocumentation\n\nEvery endpoint above is documented in the public API reference, and the CLI reference on recuerd0.ai has been updated to match. The grep→fetch-window workflow is called out as a recipe in both places, with worked examples.\n\nWhy this release matters\n\nCoding agents are getting fluent. They already know how to use glob, grep, and read — those primitives are how they navigate filesystems every day. Recuerd0’s job is not to invent a new vocabulary for context retrieval; it is to look enough like a filesystem that agents do not have to learn one.\n\nThis release is that bet, made concrete. A memory is now something you can grep. A workspace is now something you can wake up in. A long transcript no longer has to fit entirely within a context window just so the agent can quote one line from it.\n\nGet the update\n\n\n SaaS users on recuerd0.ai: the new endpoints are live now. No action needed.\n Self-hosters: pull the latest recuerd0 image (or git pull and redeploy with Kamal). Run migrations to pick up the new category column and the memory_links table.\n CLI users: you must update to the latest version to get the new memory read commands — brew upgrade recuerd0-cli (or grab the latest binary from recuerd0-cli releases). Older CLI versions will not expose the new functionality.\n Claude Code users: update the recuerd0 plugin from the Claude Code marketplace to pick up the new agent guidance and command reference. Without the plugin update, the agent will keep using the old memory show flow instead of the new grep-first patterns.\n\n\nFrequently asked questions\n\nHow do I grep a Recuerd0 memory?\nSend GET /workspaces/:id/memories/:id.json?mode=grep&q=<query>. The response returns line numbers and surrounding context instead of the full body. From the CLI: recuerd0 memory read grep <id> \"<query>\" --context 2.\n\nWhat is the difference between ranged read and grep mode?\nGrep mode finds where a string appears (returns matching line numbers with context). Ranged read fetches what is at known line numbers via line_start and line_end. The recommended workflow is grep first to locate, then ranged read to fetch a window.\n\nDo I have to update the CLI and Claude Code plugin?\nYes. The new memory read commands ship in the latest recuerd0-cli, and the grep-first agent guidance ships in the updated recuerd0 plugin in the Claude Code marketplace. Older versions will keep working but won’t expose the new endpoints.\n\nWhat are memory categories used for?\nEach memory is tagged as decision, discovery, preference, or general. Agents (and humans) can filter by category to find load-bearing decisions without sifting through general notes.\n\nDoes HTTP caching apply to grep queries?\nNo. ETag/Last-Modified caching applies to whole-memory and list reads. Grep and ranged-read responses are derived from query parameters and bypass the cache by design.\n\nRelated reading\n\n\n Announcing Recuerd0 — the original launch and the problem we set out to solve.\n Recuerd0 source code is now available — how to self-host under OSASSY.\n Maquina open-source projects — the rest of the Rails and AI tooling we maintain.\n\n\n\n\nRecuerd0 is built by Maquina. Source available under OSASSY license." }, { "id": "blog-2026-03-mvp-creator-from-idea-to-documents", "title": "MVP Creator: From Idea to Documents in Three Prompts", "collection": { "label": "posts", "name": "Blog" }, "categories": "Announcements, Tools", "tags": "", "url": "/blog/2026/03/mvp-creator-from-idea-to-documents/", "content": "Before writing a single line of code, I need to understand what I’m building. Not abstractly — concretely: who the users are, what the real problem is, what the app is called, what voice it has, what technical decisions I’m making from the start. For a long time, that work happened informally — in scattered notes, in my head, or spread across different roles on a team: product knowledge in one conversation, brand direction in another, architecture in some document nobody kept updated. Now I formalize it with an agent called MVP Creator.\n\nIn this video — the first in a series about my personal process with AI — I show how I use MVP Creator to generate the complete set of foundation documents for a new project: research report, business plan, brand guide, and technical guide. All of it with three prompts, from an initial idea to documentation ready to hand off to Claude Code.\n\n\n\n\n\n\n\nThe Three Prompts\n\nThe example in the video is a photo delivery platform for professional photographers. These are the exact prompts I use, in order.\n\n\n\nPrompt 1 — The Idea and Context\n\nHelp me create an MVP for a photo delivery platform for professional photographers.\nThink of it as a private gallery where photographers deliver finished work to clients.\n\nThe core concept: a photographer creates a Project (for a client or personal work),\norganizes photos into Collections within that project, and shares the gallery via\nsingle-use expirable links. Invited clients can view, comment, like, and download\nphotos in their preferred quality.\n\nKey features:\n- Projects with collections and high-resolution photo uploads\n- Active Storage for thumbnail + quality variants (low/medium/high)\n- Reorderable photos within collections, cover photo per collection\n- Shareable links: single-use, expire in 7 days, create read-only sessions\n- Download: single photo or multi-select as zip, with quality choice\n\nTarget users: freelance and studio photographers in Latin America\nLanguage: Spanish-first, English secondary\nApp name: I'm thinking \"Liminal\" — open to suggestions\n\nResearch these competitors: https://www.pic-time.com and\nhttps://www.picdrop.com/web — also look at how Google Drive handles\nshared folder UX as a reference point.\n\nUse the MVP Creator skill to generate the full documentation set.\n\n\nWith this prompt the agent launches competitor research, runs through the discovery questions, and generates the four foundation documents: research report, business plan, brand guide, and technical guide.\n\n\n\nPrompt 2 — Brand Voice\n\nBased on everything we've defined about Liminal — the LATAM market, photographers\ndelivering work to clients, the quiet confidence of the name itself — write a brand\nvoice document.\n\nThe voice should feel like a photographer who has found their style and doesn't need\nto announce it. Not austere, but economical. Someone who chooses words the way they\nchoose light — deliberately, with care for what gets left out as much as what stays in.\n\nProfessionalism here means craft, not corporate. The app handles something personal\n— a photographer's finished work, a client's important memories. The voice should\nhonor that weight without becoming precious about it.\n\nInfluences: the way Magnum Photos writes about their work. The directness of a good\nphoto caption. Not the breathless enthusiasm of a SaaS landing page.\n\nThe document should include:\n- Core personality traits (3–4, with explanation)\n- Tone spectrum (when to be warmer vs. more spare)\n- Vocabulary: words we use, words we avoid\n- UI microcopy examples (button labels, empty states, error messages)\n- Both Spanish and English examples side by side\n\n\nThis second prompt goes straight to the character of the app. A brand voice guide is a document that rarely gets produced in an MVP phase — and it’s one of the most useful when the time comes to write microcopy or define how the app speaks to its users.\n\n\n\nPrompt 3 — UI Mocks\n\nUsing the frontend-design skill, create UI mocks for Liminal's critical screens.\nPull from the brand guide already established and the brand voice: quiet craft,\ndeliberate, editorial — not SaaS.\n\nPrioritize these screens in order:\n\nClient-facing (unauthenticated, via share link):\n1. Gallery landing — the first thing a client sees when they open their link.\n2. Collection view — browsing photos within a collection, with like, comment,\n and download interactions visible.\n3. Download selection — choosing photos and quality before downloading as zip.\n\nPhotographer-facing (authenticated):\n4. Project dashboard — list of projects with status at a glance.\n5. Collection editor — uploading photos, reordering, setting cover photo.\n6. Share link manager — creating and tracking links, seeing which have been used.\n\nFor each screen:\n- Design for desktop first, note mobile considerations\n- Show real placeholder content — no Lorem Ipsum\n- Embed a short design rationale note explaining the key decision made for that screen\n\nAesthetic direction: editorial photography magazine meets quiet utility. The UI\nshould feel like it was designed by someone who photographs, not someone who ships\ndashboards.\n\n\nThe third prompt uses the frontend-design skill together with Maquina Components to generate HTML mocks of the critical screens. The result isn’t a Figma file — it’s a functional visual reference, coherent with the brand guide, before opening the editor.\n\n\n\nThe Result\n\nThree prompts. Six documents. Mocks of the main screens. All the context needed to hand off to Claude Code and start generating code with direction.\n\nIt’s the same process I used to build Resto, a personal finance app based on the Japanese Kakeibo method.\n\nThe video runs 40 minutes. It’s not an accelerated demo — it’s the real process, iterations and corrections included.\n\n\n\nInstallation\n\nAll plugins are available in the maquina-app/rails-claude-code repository. Full documentation at MVP Creator — Documentation.\n\nTo install MVP Creator in Claude Code:\n\n# Add the marketplace\n/plugin marketplace add maquina-app/rails-claude-code\n\n# Install the plugin\n/plugin install mvp-creator@maquina\n\n\nTo install the full set of plugins used in this series:\n\n/plugin install rails-simplifier@maquina\n/plugin install rails-upgrade-assistant@maquina\n/plugin install maquina-ui-standards@maquina\n/plugin install mvp-creator@maquina\n/plugin install better-stimulus@maquina\n/plugin install spec-driven-development@maquina\n\n\nFor the Claude graphical interface, download the repository as a zip, extract the mvp-creator folder, rename the extension to .skill, and drag it into the Claude window to install it." }, { "id": "blog-2026-03-maquina-generators-production-ready-rails-setup", "title": "Maquina Generators: From rails new to Production-Ready", "collection": { "label": "posts", "name": "Blog" }, "categories": "Announcements, Release", "tags": "", "url": "/blog/2026/03/maquina-generators-production-ready-rails-setup/", "content": "Every Rails project starts the same way. You run rails new, you get a clean app with sensible defaults, and then you do the setup work before you can write application code. Authentication with signup and multi-tenancy. Rate limiting. Background jobs with a dashboard. Error tracking. Mailer templates. Security headers. It’s repetitive, sure, but it’s the work that gets your app to the point where you can build the thing you actually sat down to build.\n\nI’ve done this enough times to know exactly what’s coming. The order changes, the names of the models drift slightly, but the shape of the work is identical. It’s not that Rails is missing anything — it’s that the space between rails new and “ready to build features” is full of choices that are mostly already made. You just have to type them out each time.\n\nMaquina Generators automate that setup. One command after rails new, and you have authentication, multi-tenancy, roles, job processing, error tracking, request protection, and ops dashboards. All generated into your app as plain Rails code. No runtime dependency.\n\nWhat Maquina Generators Do\n\nThe gem lives in your development group. It generates standalone application code — models, controllers, views, migrations, initializers, mailers — and then you can delete the gem. Nothing it produces requires the gem at runtime. No engine mounts, no middleware injection, no monkey patches. Just files in your app that you own completely.\n\nThe workflow is five commands:\n\nrails new myapp --css tailwind\nbundle add maquina-generators --group development\nrails generate maquina:app --auth clave\nbin/rails db:migrate\nbin/dev\n\n\nThat’s it. Auth with email verification codes, an Account model with roles, Rack Attack blocking scanners and throttling logins, Solid Queue with a Procfile, Solid Errors catching exceptions, Mission Control monitoring your jobs — all wired up, all running.\n\n\n\nSeven generators handle the pieces:\n\n\n \n \n Generator\n Purpose\n \n \n \n \n App\n Full application setup — orchestrates everything below\n \n \n Clave\n Passwordless email-code authentication\n \n \n Registration\n Password-based auth with accounts and roles\n \n \n Rack Attack\n Request protection and IP throttling\n \n \n Solid Queue\n Background job processing with separate database\n \n \n Solid Errors\n Error tracking dashboard\n \n \n Mission Control\n Job queue monitoring dashboard\n \n \n\n\nThe App generator is the orchestrator. It runs whichever auth generator you choose, then all the infrastructure generators in sequence. You can also run each generator independently if you only need part of the stack.\n\nThe full documentation covers every generator, option, and generated file in detail.\n\nTwo Authentication Options\n\nRails 8’s built-in rails generate authentication gives you login. It doesn’t give you signup. It doesn’t give you accounts, roles, or multi-tenancy. For most applications, login alone isn’t enough.\n\nMaquina Generators offer two complete authentication systems that pick up where Rails leaves off.\n\nClave: Passwordless\n\nClave implements passwordless authentication using email verification codes. The user enters their email, receives a 6-digit hexadecimal code, enters the code, and they’re in. No passwords to store, no password resets to build, no complexity requirements to argue about.\n\nUser enters email → receives 6-digit code → enters code → signed in\n\n\nCodes expire in 15 minutes. There’s a 15-minute cooldown before a resend. Login attempts are rate-limited to 10 per 3 minutes. Sessions last 30 days by default. Plus characters are blocked in email addresses to prevent alias attacks.\n\nBeyond sign-in, Clave generates a full multi-tenancy layer. Every user belongs to an Account. The first user who creates an account becomes its admin. A role enum — admin or member — handles authorization from there.\n\nCurrent.user # The signed-in user\nCurrent.account # The user's account\nCurrent.user.admin? # Check role\n\n\nYou scope queries through the account, and cross-tenant access is prevented at the model level:\n\n@projects = Current.account.projects\n\n\nClave generates models, controllers, a mailer with HTML and text templates, a daily cleanup job for expired sessions and codes, a test helper with sign_in_as(user), and full i18n support in English and Spanish.\n\nRegistration: Password-Based\n\nIf you prefer passwords, the Registration generator builds on Rails 8’s authentication. It runs rails generate authentication first, then adds what’s missing: an Account model, belongs_to :account on User, the role enum, a RegistrationsController that creates an Account and User in a single transaction, and Tailwind-styled views.\n\nclass RegistrationsController < ApplicationController\n allow_unauthenticated_access\n rate_limit to: 10, within: 3.minutes, only: :create\n\n def create\n ActiveRecord::Base.transaction do\n account = Account.create!(name: params[:account_name])\n user = account.users.create!(\n name: params[:name],\n email_address: params[:email_address],\n password: params[:password],\n role: :admin\n )\n end\n start_new_session_for user\n redirect_to root_path\n end\nend\n\n\n\n\nSame Current.user, Current.account, and role-based authorization as Clave. The multi-tenancy pattern is identical — only the sign-in mechanism differs.\n\nThe generators documentation covers every option, model, and controller for both auth systems.\n\nThe Ops Layer\n\nAuthentication is the most visible piece, but the App generator does more than auth. It sets up a complete operational layer that most Rails apps need but few have on day one.\n\nRack Attack gets configured with real-world defaults. PHP file requests, WordPress scanning paths, .env and .git probes — all blocked immediately. Sensitive paths like /cgi-bin, /phpmyadmin, and /actuator return 403. General traffic is throttled to 300 requests per 5 minutes per IP, with asset paths exempted. Login endpoints get tighter limits: 5 attempts per 20 seconds.\n\nSolid Queue is set up as the Active Job backend with its own SQLite database, a Procfile entry for the worker process, and a recurring schedule that runs the authentication cleanup job daily at 3am. The configuration lives in config/solid_queue.yml — three worker threads, half-second polling, standard dispatching.\n\nSolid Errors and Mission Control Jobs get mounted as dashboards with custom Tailwind views. Mission Control alone has 41 view files — job listings, queue status, worker monitoring, recurring task management — all styled to match your application instead of looking like a default engine mount.\n\nBoth dashboards share the same HTTP basic auth credentials:\n\n# bin/rails credentials:edit\nbackstage:\n username: admin\n password: your_secure_password\n\n\nOne set of credentials, stored in Rails credentials. Environment variable fallbacks if you prefer. After running the generators, you have /admin/solid_errors and /admin/mission_control_jobs working from the first bin/dev.\n\n\n\nThe App generator also sets up multi-database configuration — separate SQLite databases for the queue, cache, cable, and errors — installs Active Storage and Action Text, configures Turbo morphing, adds brakeman and Standard for code quality, and creates a HomeController with a root route. It’s the full post-rails new checklist, automated.\n\nOwn the Code\n\nThis is the part that matters most. Maquina Generators is a development-only gem. It generates code into your application and then it’s done. You can — and should — delete it from your Gemfile once you’ve run the generators.\n\n# Gemfile — remove after generating\ngroup :development do\n gem \"maquina-generators\"\nend\n\n\nEvery file it produces is a standard Rails file in a standard location. Models in app/models, controllers in app/controllers, views in app/views, initializers in config/initializers. No engine, no namespace, no gem dependency at runtime. If you want to change how sessions expire, you edit app/controllers/concerns/authentication.rb. If you want different Rack Attack rules, you edit config/initializers/rack_attack.rb. If you want to add a third role beyond admin and member, you update the enum on User.\n\nThere’s no DSL to learn, no configuration file to maintain, no version upgrades to track. The generated code follows Rails conventions because it is Rails code. You can read every line, understand every decision, and change anything that doesn’t fit your project.\n\nThis connects to the broader Maquina ecosystem. The generators set up the foundation — auth, security, ops tooling. Maquina Components handles the UI layer with ViewComponent-based partials that the App generator installs automatically. When you start building features on top of this foundation, Rails Simplifier keeps AI-generated code idiomatic, and the MCP Server gives AI tools visibility into your codebase structure.\n\nEach tool is independent. Use one, use all, use none. No lock-in at any layer.\n\nGet Started\n\nInstall the gem and run the app generator:\n\nrails new myapp --css tailwind\ncd myapp\nbundle add maquina-generators --group development\nrails generate maquina:app --auth clave\nbin/rails db:migrate\nbin/dev\n\n\nChoose --auth clave for passwordless, --auth registration for passwords, or --auth none if you want the infrastructure without authentication.\n\nFull documentation is at maquina.app/documentation/generators. Source code is on GitHub." }, { "id": "blog-2026-02-recuerd0-source-code-now-available", "title": "Recuerd0 Source Code Is Now Available", "collection": { "label": "posts", "name": "Blog" }, "categories": "Announcements, Product", "tags": "", "url": "/blog/2026/02/recuerd0-source-code-now-available/", "content": "The self-hosted promise is fulfilled — Recuerd0’s source code is on GitHub.\n\n\n\nWhen we announced Recuerd0, we said the self-hosted version would be available pretty soon. Today it is. The full source code is on GitHub under the OSASSY license.\n\nThis is not a stripped-down edition. It’s the same codebase that runs recuerd0.ai — every feature, every endpoint, every migration.\n\nUnder the hood\n\nRecuerd0 is a Rails 8.1 application running on Ruby 4.0. The entire stack leans into the One Person Framework philosophy: minimize infrastructure, eliminate external dependencies, ship with confidence.\n\nSQLite for everything. Data, cache, queue, and cable — all backed by SQLite. No Postgres. No Redis. Solid Queue handles background jobs, Solid Cache handles caching, and Solid Cable handles WebSocket connections. One database engine, zero extra services.\n\nNo Node.js. The frontend uses Propshaft for asset delivery and Importmaps for JavaScript modules. Hotwire (Turbo + Stimulus) handles interactivity. Tailwind CSS 4 handles styling. The entire frontend pipeline runs without a JS build step.\n\nFull-text search with FTS5. Search is powered by SQLite’s FTS5 extension — no vector database, no embeddings, no RAG pipeline. The index updates on every write, returns results in milliseconds, and is fully deterministic. The agent decides what to search for; the database does the rest.\n\nMemory versioning. Every memory supports a flat branching model — create new versions from any point in history. Soft deletion with 30-day retention means nothing disappears by accident.\n\nMulti-tenancy. The Account model supports multiple tenants. In single-tenant mode (the default for self-hosted), public registration is disabled — you control who has access.\n\nUI components. The interface is built with the maquina-components gem, the same component library used across all Maquina projects.\n\nGetting started\n\nTwo paths to self-host:\n\nDocker image. Pull the ready-to-use Docker image and deploy. Configure your environment variables and you’re running.\n\nFrom source. Clone the repository, configure Kamal 2.x, and deploy to your server. The included Dockerfile and Kamal configuration handle the rest. Thruster sits in front of Puma, and SOLID_QUEUE_IN_PUMA=true runs background jobs in-process — one container, one process, everything included.\n\nSingle-tenant mode is the default. No public registration, no setup wizard. Deploy, create your account, start curating context.\n\nLicense\n\nRecuerd0 is released under the OSASSY license. It’s essentially MIT with one restriction: you can’t take the code and offer it as a competing hosted service. The same model 37signals uses. Deploy it on your infrastructure, modify it, use it internally — free forever.\n\nNot interested in self-hosting?\n\nRecuerd0 SaaS is $15/month for up to 10 users — managed hosting, automatic backups, and updates. Read the full product announcement for the complete story.\n\nThe source is on GitHub. Do what you want with it.\n\nView the repository →\n\n\n\nRecuerd0 is built by Maquina. Source available under OSASSY license." }, { "id": "blog-2026-02-announcing-recuerd0", "title": "Announcing Recuerd0: A Knowledge Base for AI Tool Context", "collection": { "label": "posts", "name": "Blog" }, "categories": "Announcements, Product", "tags": "", "url": "/blog/2026/02/announcing-recuerd0/", "content": "Organize, version, and serve project context to any LLM — from Claude Code to Cursor to ChatGPT.\n\n\n\nEvery AI coding tool starts each session with amnesia. Your architecture decisions, naming conventions, and deployment quirks — none of it carries over. You re-explain the same context with the same tools every single day.\n\nThe common workarounds are CLAUDE.md files, .cursorrules, AGENTS.md — each tool with its own configuration format. You end up duplicating knowledge across multiple places. They drift apart. Your Claude Code config says one thing; your Cursor rules say another.\n\nRecuerd0 is a dedicated knowledge base for managing the context your AI tools consume. You curate project knowledge once and serve it to every tool via REST API.\n\n\n\nHow it works\n\nWorkspaces group knowledge by project or domain. Backend conventions in one workspace, frontend patterns in another, org-wide standards in a shared workspace.\n\n\n\nMemories are versioned markdown documents with titles, tags, and full history. When conventions evolve, you create a new version — like Git for context. Branch from any version, track how decisions changed, and never lose the rationale.\n\n\n\nAccess is through a REST API with Bearer token authentication. Any tool that can make an HTTP request reads from the same source. There’s also a CLI for terminal workflows and a Claude Code plugin for tighter integration.\n\nSearch uses the database’s full-text search with millisecond performance. No embeddings, no vector database, no RAG pipeline. The agent decides what to search for and how to refine the search. The index updates on every write, is deterministic, and requires zero infrastructure beyond the database.\n\nArchitecture decisions\n\nHuman-curated, not auto-captured. Automatic knowledge capture sounds appealing, but it produces noisy results — context-specific fixes that don’t generalize, contradictory items as conventions evolve. The human decides what’s worth persisting. The team reviews and evolves it.\n\nTool-agnostic by design. We built an API, not a plugin for one tool. Your knowledge base survives any tool change. Claude Code, Cursor, ChatGPT, Windsurf, custom scripts, CI/CD pipelines — same context, same source.\n\nSmall and focused. Recuerd0 is designed for a small set of focused memories per workspace philosophy, not thousands of files. The constraint forces curation. When the workspace is focused, the right answer is obvious without sophisticated search algorithms.\n\nPricing\n\nRecuerd0 SaaS is $15/month for up to 10 users. Managed hosting, automatic backups, updates, and email support. Create an account and start in minutes at recuerd0.ai.\n\nTeams of 6 or more can contact us for custom plans.\n\nSelf-hosted is available under the OSASSY license — the same model 37signals uses for Fizzy. It’s essentially MIT with one addition: you can’t take the code and offer it as a competing hosted service. Deploy on your server, modify the code, use it internally — free forever.\n\nThe self-hosted version is not available at launch, but it will be available pretty soon.\n\nGet started\n\nThe API documentation covers every endpoint. The CLI reference has installation and commands. The agent workflows guide shows how to integrate with Claude Code, Cursor, and other tools.\n\nContext engineering has become a core developer skill. It deserves a dedicated tool.\n\nStart with Recuerd0 SaaS →\n\n\n\nRecuerd0 is built by Maquina. Source available under OSASSY license." }, { "id": "blog-2026-02-maquina-components-0-4-0-turbo-compatibility", "title": "Maquina Components 0.4.0: Taming Turbo", "collection": { "label": "posts", "name": "Blog" }, "categories": "Announcements, Release", "tags": "", "url": "/blog/2026/02/maquina-components-0-4-0-turbo-compatibility/", "content": "I was working on a Rails application—standard CRUD with a sidebar and a few interactive menus. Everything worked on first load. Then I navigated away and came back. The sidebar was gone. I opened a dropdown, clicked a Turbo link, hit the back button. The dropdown was still open, sitting there on top of a page that had already moved on.\n\nIf you’ve built anything with Turbo and Stimulus beyond basic forms, you’ve likely seen this. Components work fine on full page loads, but Turbo introduces a different lifecycle. Pages get cached mid-state, morphs overwrite client-side changes with stale server HTML, and your UI ends up stuck in states it should have left behind.\n\nFixing this in Maquina Components is what version 0.4.0 is about.\n\nThe Teardown Pattern\n\nThe core problem is described well by Better Stimulus. When Turbo navigates away from a page, it takes a snapshot of the DOM before leaving. When the user returns, Turbo shows that snapshot first. Any DOM changes your Stimulus controllers made—open menus, expanded panels, loading classes—get frozen into the cache.\n\nThe standard Stimulus disconnect callback handles general cleanup, but it doesn’t distinguish between “the element was removed from the DOM” and “Turbo is about to cache this page.” You need both.\n\nThe Teardown pattern adds a teardown method to controllers, triggered by Turbo’s turbo:before-cache event. Every controller that manipulates the DOM can opt in, resetting its visual state before Turbo takes the snapshot. This keeps disconnect clean for general lifecycle concerns and gives Turbo-specific rollback its own dedicated path.\n\nThis release applies that pattern across the interactive components in the library.\n\nSidebar: Three Problems at Once\n\nThe sidebar was the hardest to get right. It had three separate issues interacting with each other.\n\nRandom IDs broke morphing. The sidebar generated IDs like sidebar-a3f9b2 on every render. Turbo’s idiomorph algorithm matches elements by ID—when the ID changes every time, idiomorph can’t find the element and treats it as new. Every morph was destroying and recreating the sidebar from scratch. The fix: deterministic IDs. sidebar-left and sidebar-right, consistent across renders. The sidebar provider also gets a stable ID (sidebar-provider by default).\n\nMorphs overwrote client state. The sidebar stores its open/closed state in a cookie so it persists across page loads. During a Turbo morph, the server sends back HTML with the default state—it doesn’t know about the cookie. Idiomorph applies the server HTML, and the sidebar collapses even though the user had it open.\n\nThe fix adds a turbo:before-morph-element listener with a _morphing guard flag. When a morph happens, the controller reads the cookie (the source of truth on the client), reasserts the correct state, and strips the sidebar-loading class that the server HTML reintroduces.\n\nLayout shift on desktop. When Stimulus initialized and switched the sidebar from its mobile offcanvas mode to the desktop collapsible mode, there was a visible jump. The transition happened after the browser had already painted. This release smooths that handoff so the mode switch doesn’t cause a flash.\n\nThe Yield Trap\n\nThe second category of fixes has nothing to do with Turbo. It’s a Rails rendering behavior that caught me off guard.\n\nNine partials in the library used the standard block pattern:\n\n<%= render \"components/card/description\" do %>\n <p>Custom HTML</p>\n<% end %>\n\n\nThis works when you always pass a block. But render the partial without a block and yield inside it doesn’t return nothing—it renders the entire page’s content into the partial. Rails treats the missing block as a signal to yield the page-level content instead.\n\nThe result: components rendering the full page body inside a card title or a toast message. It only shows up in specific usage patterns, and when it does, the output looks completely wrong with no obvious cause.\n\nThe fix replaces yield with an explicit content: parameter in all nine affected partials:\n\n\n card/title, card/description\n alert/title, alert/description\n toast/title, toast/description\n combobox/label, toast (main), toaster\n\n\nThe five toast helper methods no longer accept blocks either.\n\nBreaking Changes\n\nThis is a minor version bump with breaking changes:\n\n\n Block syntax removed for the 9 partials listed above. Use content: capture { ... } instead of do ... end.\n Toast helpers no longer accept blocks. Use the content: parameter.\n Sidebar IDs changed from sidebar-<random_hex> to sidebar-left / sidebar-right.\n Sidebar provider now has a stable id attribute (sidebar-provider by default).\n\n\nMigration\n\nThe content parameter change is mechanical. Find every block-style call to the affected partials and wrap the content with capture:\n\n<%= render \"components/card/description\" do %>\n <p>Custom HTML</p>\n<% end %>\n\n<%= render \"components/card/description\",\n content: capture { %>\n <p>Custom HTML</p>\n<% } %>\n\n\nFor sidebar IDs, if you reference specific sidebar element IDs in JavaScript or tests, update them to sidebar-left or sidebar-right.\n\nUpgrading\n\nbundle update maquina_components\n\n\nWhat This Reinforced\n\nTurbo is not a transparent layer over page loads. It’s a different execution model. Any Stimulus controller that touches the DOM needs to account for caching, morphing, and the gap between what the server renders and what the client has changed since. The Teardown pattern should be the default starting point for any controller that does more than read values.\n\nThe yield behavior in Rails partials was a genuine surprise. It’s documented, but it’s a quiet trap when you have optional block content. Explicit parameters are safer.\n\nDocumentation\n\n\n Component Documentation\n Sidebar\n Card\n Alert\n Toast\n Combobox\n\n\nSource\n\n\n Maquina Components\n Full Changelog\n Better Stimulus: Teardown Pattern" }, { "id": "blog-2026-01-maquina-0-3-1-calendar-date-picker-claude-skills", "title": "Maquina 0.3.1: Calendar, Date Picker & Claude Code Skills", "collection": { "label": "posts", "name": "Blog" }, "categories": "Announcements, Release", "tags": "", "url": "/blog/2026/01/maquina-0-3-1-calendar-date-picker-claude-skills/", "content": "This month brings updates across the Maquina ecosystem: new Calendar and Date Picker components for Rails, two Claude Code skills for AI-assisted development, and live interactive previews for all components in the documentation.\n\nMaquina Components 0.3.1\n\nBuilding on version 0.3.0, this release adds two components for date selection: Calendar and Date Picker.\n\nCalendar\n\nAn inline calendar for single date or range selection. Useful when you need the full calendar visible—booking flows, availability displays, or any context where date proximity matters.\n\n<%= render \"components/calendar\",\n mode: :range,\n selected: Date.today,\n selected_end: Date.today + 5 %>\n\n\nFor form integration, the calendar generates hidden inputs automatically:\n\n<%= form_with model: @booking do |f| %>\n <%= render \"components/calendar\",\n mode: :range,\n input_name: \"booking[check_in]\",\n input_name_end: \"booking[check_out]\" %>\n<% end %>\n\n\nFeatures: Single or range selection, min/max date constraints, disabled dates, week start configuration, and direct form integration with hidden inputs.\n\nDate Picker\n\nA button that opens a calendar in a popover. Better for forms where space is limited and you don’t need the calendar always visible.\n\n<%= render \"components/date_picker\",\n mode: :single,\n placeholder: \"Select a date\",\n input_name: \"event[date]\" %>\n\n\nRange selection works the same way:\n\n<%= render \"components/date_picker\",\n mode: :range,\n placeholder: \"Select date range\",\n input_name: \"start_date\",\n input_name_end: \"end_date\" %>\n\n\nFeatures: Single or range selection, pre-selected date display, min/max boundaries, disabled state, and customizable placeholders.\n\nWhen to Use Which\n\n\n \n \n Use Case\n Component\n \n \n \n \n Booking calendar with visible availability\n Calendar\n \n \n Date field in a form\n Date Picker\n \n \n Date range with context (prices, events)\n Calendar\n \n \n Quick date selection in limited space\n Date Picker\n \n \n\n\nLive Previews\n\n\n\nThe documentation site now includes live, interactive previews for all components. Visit any component page in the documentation to see working examples in light and dark themes, multiple color variations, and code ready to copy.\n\nFor a complete showcase, the live demo application shows all components working together with sample data.\n\nUpgrading\n\nbundle update maquina_components\n\n\nNo generator changes required for existing installations.\n\nClaude Code Skills\n\nTwo new skills for AI-assisted Rails development.\n\nMaquina UI Standards\n\nTeaches Claude how to build UIs with maquina_components. Without guidance, Claude generates generic Rails patterns—plain divs, inline styles, inconsistent markup. With this skill, Claude generates code using your actual component library.\n\nIncludes: Component catalog with 20+ components, form patterns, layout patterns, Turbo integration, and accessibility guidelines.\n\n/plugin marketplace add maquina-app/rails-claude-code\n/plugin install maquina-ui-standards@maquina\n\n\nAsk Claude “Create a users index view with a table” and get:\n\n<%= render \"components/card\" do %>\n <%= render \"components/card/header\" do %>\n <%= render \"components/card/title\", text: \"Users\" %>\n <% end %>\n <%= render \"components/card/content\" do %>\n <%= render \"components/table\" do |t| %>\n <% t.header do %>\n <% t.head_cell \"Name\" %>\n <% t.head_cell \"Email\" %>\n <% end %>\n <% end %>\n <% end %>\n<% end %>\n\n\nDocumentation: Maquina UI Standards\n\nRails Simplifier\n\nRefines Rails code following 37signals patterns and the One Person Framework philosophy.\n\nWhat it does: Converts service objects to model methods, transforms custom actions to CRUD resources, moves logic from controllers to models, detects N+1 queries, and applies Rails conventions like I18n and Time.current.\n\n/plugin marketplace add maquina-app/rails-claude-code\n/plugin install rails-simplifier@maquina\n\n\nExample prompts:\n\n> Review recent changes using the rails-simplifier agent\n> Use rails-simplifier to review the bookings controller\n\n\nDocumentation: Rails Simplifier\n\nSource\n\nAll projects are MIT licensed:\n\n\n Maquina Components\n Rails Claude Code Skills" }, { "id": "blog-2026-01-claude-skill-for-maquina-components", "title": "Claude Skill for Maquina Components", "collection": { "label": "posts", "name": "Blog" }, "categories": "Announcements, Tools", "tags": "", "url": "/blog/2026/01/claude-skill-for-maquina-components/", "content": "When I started extracting and standardizing maquina_components from real production applications, I was also experimenting with AI-assisted development. The two efforts ran in parallel—building a consistent component library while trying to get Claude to help me use it.\n\nThe results were mixed. Every time I asked Claude to build a view or implement a form, it was back and forth. “Use the card partial, not a div.” “The input needs a data attribute.” “That’s not how the combobox works.” I spent as much time correcting the AI as I would have spent writing the code myself.\n\nThe same friction appeared when writing specs. I’d describe a feature and Claude would suggest generic Rails patterns instead of the components I had available. It didn’t know about the library. How could it?\n\nThe Skill Experiment\n\nWhen Anthropic released the Skills functionality, I wondered if it was the right tool for this problem. Skills let you teach Claude project-specific knowledge—conventions, patterns, APIs. Exactly what was missing.\n\nI created a first version: a structured reference with component examples, form patterns, layout conventions, and Turbo integration guides. Added it to my projects and started using it.\n\nIt worked. Claude started generating code that matched my conventions. The combobox had proper keyboard navigation. Forms used the right data attributes. Turbo Streams updated components correctly. The back-and-forth dropped significantly.\n\nI kept the skill private. It was tied to my workflow, my projects. Not ready for others.\n\nMaking It Public\n\nYesterday I published Maquina Components 0.3.0 with Combobox and Toast. Shortly after, someone asked if I had an MCP server for the components.\n\nI replied that I had something better—a Claude Skill that I’d been using for while now. It was working great with the gem.\n\nSo I decided to open source it.\n\nWhat the Skill Provides\n\nA complete reference for building UIs with maquina_components:\n\n\n \n \n Reference\n Purpose\n \n \n \n \n Component catalog\n All 15+ components with ERB examples\n \n \n Form patterns\n Validation, error handling, inline layouts\n \n \n Layout patterns\n Sidebar navigation, page structure\n \n \n Turbo integration\n Frames, Streams, component updates\n \n \n Spec checklist\n Review criteria for UI quality\n \n \n\n\nInstallation\n\nCreate a skills directory in your Rails project:\n\ncd your-rails-app\nmkdir -p .claude/skills\n\n\nDownload the skill from the maquina_components repository and copy it to .claude/skills/maquina-ui-standards/.\n\nThen update your CLAUDE.md to reference it:\n\n## UI Components\n\nThis project uses maquina_components for UI. Before implementing views,\nforms, or interactive components, read the UI standards skill:\n\n.claude/skills/maquina-ui-standards/SKILL.md\n\nAlways consult the skill when:\n- Creating or modifying views\n- Implementing forms\n- Adding interactive components\n- Building layouts with sidebar/header patterns\n- Working with Turbo Streams that update UI\n\n\nUsage\n\nOnce installed, ask Claude naturally:\n\nCreate the users index view with a table showing name, email, and status.\n\n\nImplement the project form with name, description, and a framework combobox.\n\n\nReview this view against the maquina UI standards and suggest improvements.\n\n\nThe generated code matches what you’d write manually—just faster, and without the back-and-forth.\n\nSource\n\nThe skill is included in the maquina_components repository under MIT license. Updates follow gem releases." }, { "id": "blog-2026-01-maquina-components-0.3.0-combobox-and-toast", "title": "Maquina Components 0.3.0: Combobox and Toast", "collection": { "label": "posts", "name": "Blog" }, "categories": "Announcements, Release", "tags": "", "url": "/blog/2026/01/maquina-components-0.3.0-combobox-and-toast/", "content": "Version 0.3.0 of Maquina Components adds two frequently requested interactive components: Combobox and Toast.\n\nBoth components follow the same philosophy as the rest of the library—ERB partials, Tailwind CSS, and Stimulus controllers only where necessary.\n\nCombobox\n\n\n\nAn autocomplete input with a searchable dropdown list. Useful when selecting from many options—countries, users, tags, or any list that benefits from filtering.\n\n<%= combobox placeholder: \"Select framework...\" do |cb| %>\n <% cb.trigger %>\n <% cb.content do %>\n <% cb.input placeholder: \"Search...\" %>\n <% cb.list do %>\n <% cb.option value: \"rails\" do %>Ruby on Rails<% end %>\n <% cb.option value: \"django\" do %>Django<% end %>\n <% cb.option value: \"phoenix\" do %>Phoenix<% end %>\n <% end %>\n <% cb.empty %>\n <% end %>\n<% end %>\n\n\nFor simpler use cases, the data-driven helper builds the entire structure from an array:\n\n<%= combobox_simple placeholder: \"Select country...\",\n name: \"user[country]\",\n options: Country.all.map { |c| { value: c.code, label: c.name } } %>\n\n\nFeatures\n\n\n Keyboard navigation (arrows, Home, End, Escape)\n Real-time filtering as you type\n Grouped options with labels and separators\n Multiple width and alignment options\n Full ARIA support (role=\"combobox\", role=\"listbox\")\n\n\nRequirements\n\nThe Combobox uses the HTML5 Popover API for light-dismiss behavior. Most modern browsers support it natively:\n\n\n \n \n Browser\n Version\n \n \n \n \n Chrome\n 114+\n \n \n Edge\n 114+\n \n \n Safari\n 17+\n \n \n Firefox\n 125+\n \n \n\n\nFor older browsers, add the popover polyfill:\n\nnpm install @oddbird/popover-polyfill\n\n\n// app/javascript/application.js\nimport \"@oddbird/popover-polyfill\"\n\n\nToast\n\n\n\nNon-intrusive notifications that appear temporarily and dismiss automatically. Ideal for form submission feedback, background task completion, or any transient message.\n\nServer-Side with Flash Messages\n\nThe most common pattern—render Rails flash messages as toasts:\n\n<%= render \"components/toaster\", position: :bottom_right do %>\n <%= toast_flash_messages %>\n<% end %>\n\n\n# In your controller\nflash[:success] = \"Profile updated successfully!\"\nredirect_to @user\n\n\nFlash types map automatically to toast variants: :success, :error, :warning, :info.\n\nJavaScript API\n\nFor dynamic notifications without a page reload:\n\nToast.success(\"Changes saved!\")\n\nToast.error(\"Connection lost\", {\n description: \"Please check your internet connection.\"\n})\n\nToast.warning(\"Session expiring\", { duration: 10000 })\n\n// Dismiss programmatically\nconst id = Toast.info(\"Processing...\")\nToast.dismiss(id)\n\n\nWith Turbo Streams\n\nAppend toasts to the container in Turbo Stream responses:\n\n<%= turbo_stream.append \"toaster\" do %>\n <%= toast :success, \"Post published!\" %>\n<% end %>\n\n\nFeatures\n\n\n Five variants: default, success, info, warning, error\n Auto-dismiss with configurable duration (pauses on hover)\n Six positioning options (corners and center edges)\n Optional action buttons for undo/view operations\n Full keyboard accessibility\n\n\nRequirements\n\nToast requires Stimulus for the auto-dismiss timer and JavaScript API. Add the controller to your Stimulus application:\n\n// app/javascript/application.js\nimport { Application } from \"@hotwired/stimulus\"\nimport { eagerLoadControllersFrom } from \"@hotwired/stimulus-loading\"\n\nconst application = Application.start()\neagerLoadControllersFrom(\"controllers\", application)\n\n\nUpgrading\n\nbundle update maquina_components\n\n\nNo generator changes are required. Both components use the existing theme variables.\n\nSee Them in Action\n\nTo explore Combobox, Toast, and all other components with demo data, clone the repository and run the dummy application:\n\ngit clone https://github.com/maquina-app/maquina_components.git\ncd maquina_components/test/dummy\nbin/dev\n\n\nThen visit http://localhost:5300 to interact with the full component showcase.\n\nDocumentation\n\n\n Combobox documentation\n Toast documentation\n Full component list\n\n\nSource\n\nThe gem is MIT licensed. Source and issues on GitHub." }, { "id": "blog-2025-12-rails-mcp-server-1-5-0-security-hardening", "title": "Rails MCP Server 1.5.0: Security Hardening and Sandboxed Environment Support", "collection": { "label": "posts", "name": "Blog" }, "categories": "Announcements, Release, AI Tools", "tags": "", "url": "/blog/2025/12/rails-mcp-server-1-5-0-security-hardening/", "content": "Open source projects get better when people contribute back. Rails MCP Server 1.5.0 is a direct result of that—a release shaped significantly by a community contribution that I didn’t write.\n\nThe most important change in this version is a comprehensive security overhaul contributed by GitHub user hellvinz through PR #25. It’s the kind of work that doesn’t get enough recognition.\n\nThe Security Contribution\n\nWhen you give an AI model access to your codebase through MCP tools, security matters. The execute_ruby sandbox already restricted dangerous operations, but the file-accessing analyzers needed more rigorous input validation.\n\nPathValidator Module\n\nA centralized validation layer now protects all file-accessing analyzers. Path traversal attempts are blocked. Sensitive files are filtered automatically. The implementation is clean:\n\n# Path traversal attempts are blocked\nget_file(path: \"../../../etc/passwd\")\n# => \"Access denied: Path is outside the project directory\"\n\n# Sensitive files are filtered\nlist_files(pattern: \"config/*.key\")\n# => master.key, credentials.yml.enc excluded from results\n\n\nThe validator catches:\n\n Path traversal attacks (../ sequences)\n Absolute path access outside the project\n Attempts to read sensitive files (master.key, credentials.yml.enc, .env)\n\n\nInjection Prevention\n\nShell commands now use IO.popen with array arguments instead of string interpolation. Table names in schema queries are validated against a strict pattern. These changes close potential injection vectors that existed in earlier versions.\n\nCI Infrastructure\n\nBeyond the code changes, hellvinz added security infrastructure I should have set up from the start:\n\n Dependabot for dependency updates\n CodeQL for static analysis\n OpenSSF Scorecard integration\n A proper SECURITY.md for vulnerability reporting\n\n\nThis kind of contribution takes real effort. Reviewing an unfamiliar codebase, identifying gaps, implementing fixes that don’t break existing functionality, unglamorous work that makes the project better for everyone who uses it.\n\nI’m grateful for the contribution.\n\nSandboxed Environment Support\n\nAI coding agents increasingly run in sandboxed environments—containers or restricted shells where they can only access the current project directory. GitHub Copilot Agent and Claude Code Agent both work this way.\n\nPrevious versions of Rails MCP Server assumed access to a user home directory for configuration files. That doesn’t work in a sandbox.\n\nThe --single-project flag solves this. It tells the server to use the current working directory as the only project, skipping configuration files entirely:\n\nrails-mcp-server --single-project\n\n\nGitHub Copilot Agent configuration goes in .vscode/mcp.json:\n\n{\n \"servers\": {\n \"rails-mcp\": {\n \"command\": \"rails-mcp-server\",\n \"args\": [\"--single-project\"]\n }\n }\n}\n\n\nClaude Code Agent can use the same flag. The server detects it’s running in a Rails directory and works immediately—no setup required.\n\nThis also simplifies CI/CD pipelines and any environment where you want the server to just work with the current directory.\n\nThe Copilot Agent documentation covers the setup in detail.\n\nSimplified Project Configuration\n\nPrevious versions required manual configuration in ~/.config/rails-mcp/projects.yml. That still works, but 1.5.0 adds flexibility:\n\n\n \n \n Method\n Use Case\n \n \n \n \n --single-project flag\n Sandboxed agents (Copilot, Claude Code), CI/CD\n \n \n RAILS_MCP_PROJECT_PATH env var\n Explicit path control\n \n \n Auto-detection\n Finds Rails apps from Gemfile, engines from gemspec\n \n \n projects.yml\n Multiple projects with named references\n \n \n\n\nThe server now auto-detects Rails applications by checking for a Gemfile with the rails gem, and Rails engines by looking for gemspec files with Rails dependencies. When only one project is available, it switches automatically.\n\nRails 8.1 Compatibility\n\nRails 8.1 changed the internal callback API. The analyze_controller_views tool was calling callback.options to extract :only and :except conditions, but that method no longer exists.\n\nThe fix maintains backward compatibility:\n\ncallbacks: controller._process_action_callbacks.map { |cb|\n h = { kind: cb.kind.to_s, filter: cb.filter.to_s }\n if cb.respond_to?(:options)\n h[:only] = Array(cb.options[:only]).map(&:to_s)\n h[:except] = Array(cb.options[:except]).map(&:to_s)\n end\n h\n}\n\n\nThis works with Rails 6.0 through 8.1. The callback conditions are extracted when available, omitted when not.\n\nOther Changes\n\nError messages now include hints. When you ask for a model named users instead of User, the error explains the naming convention. Small things that reduce friction.\n\nParameter passing in execute_tool is fixed. The params schema now generates correctly for MCP clients, so tools like analyze_models can actually receive their parameters. This was a real bug that made the tool harder to use than it should have been.\n\nInput validation for load_guide prevents path traversal in guide names. Another gap that hellvinz’s security review prompted me to address.\n\nBreaking Change\n\nThe load_guide analyzer renamed its parameter from guides to library:\n\n# Before (1.4.x)\nexecute_tool(\"load_guide\", { guides: \"rails\", guide: \"active_record\" })\n\n# After (1.5.0)\nexecute_tool(\"load_guide\", { library: \"rails\", guide: \"active_record\" })\n\n\nThe change clarifies that you’re selecting a documentation library (rails, turbo, stimulus, kamal, custom), not multiple guides. It’s a small breaking change, but the naming is more accurate.\n\nUpgrading\n\ngem update rails-mcp-server\n\n\nIf you’re using Claude Desktop, restart it to pick up the new version. The server binary path in your configuration doesn’t change.\n\nFor new installations:\n\ngem install rails-mcp-server\nrails-mcp-config\n\n\nThe interactive configuration tool handles Claude Desktop setup, project registration, and guide downloads.\n\nWhat’s Next\n\nThe MCP specification continues to evolve. As more AI tools adopt the protocol, Rails MCP Server will adapt to support them.\n\nIf you find issues or have ideas, the issue tracker is open. Pull requests are welcome. As this release shows, community contributions make a real difference—sometimes more than you might expect.\n\nLinks\n\n\n GitHub Repository\n RubyGems\n Documentation\n AI Agent Guide\n Copilot Agent Setup" }, { "id": "blog-2025-12-announcing-maquina-components-opinionated-ul-for-rails-applications", "title": "Announcing Maquina Components: Opinionated Ul for Rails Applications", "collection": { "label": "posts", "name": "Blog" }, "categories": "Announcements, Release", "tags": "", "url": "/blog/2025/12/announcing-maquina-components-opinionated-ul-for-rails-applications/", "content": "Rails has opinions about most things. Database migrations, routing, asset handling, background jobs. But when it comes to building user interfaces, you’re on your own.\n\nThe framework gives you excellent primitives: importmaps, Stimulus, Turbo. But no default components. No standard way to build a button, a card, or a data table. Every Rails developer reinvents these from scratch.\n\nMaquina Components is my attempt to fill this gap—not the definitive solution, but one practical approach that works for how I build applications.\n\nWhy This Exists\n\nI started building components inspired by shadcn/ui for production Rails applications—dashboards, admin interfaces, internal tools. Over time, these components spread across multiple projects and became inconsistent: different APIs, different styling approaches, different levels of completeness.\n\nIt was time to extract the elements I use most and give them a cohesive API and consistent styling.\n\nThe Technical Choices\n\nI chose ERB partials with Tailwind CSS and Stimulus controllers for interactive elements. For static components like form inputs, pure CSS with data attributes is enough.\n\n<%= render \"components/card\" do %>\n <%= render \"components/card/header\" do %>\n <%= render \"components/card/title\", text: \"Projects\" %>\n <% end %>\n <%= render \"components/card/content\" do %>\n <%= render \"components/table\", collection: @projects %>\n <% end %>\n<% end %>\n\n\nI’m aware of alternatives like ViewComponent and Phlex. The projects I extracted these components from didn’t use them. I see the benefits of using a Ruby class to render UI, but bringing either library into a project is a commitment—not all teams are ready to make it.\n\nThe reason isn’t technical. It’s the perception of moving away from “the Rails way.” ERB partials are what Rails developers learn first. They’re simple, they work, and everyone understands them immediately.\n\nWhat’s Included\n\nTwelve components extracted from production applications:\n\n\n \n \n Category\n Components\n \n \n \n \n Layout\n Sidebar, Header\n \n \n Content\n Card, Alert, Badge, Table, Empty State\n \n \n Navigation\n Breadcrumbs, Dropdown Menu, Pagination\n \n \n Interactive\n Toggle Group\n \n \n Forms\n Input, Select, Checkbox, Button (via data attributes)\n \n \n\n\nEach component follows the shadcn/ui theming convention with CSS variables. Light and dark mode work out of the box.\n\nComposability Over Convenience\n\nThese components are intentionally small. A card is five partials: wrapper, header, title, description, content, footer. That’s more code to write than a single <%= card(...) %> helper.\n\nBut composition is the point. You take these partials and build larger, application-specific components. A ProjectCard that combines Card + Badge + Button. A UserTable that extends Table with custom columns. There are no limits because you own the abstraction layer.\n\nWhat I Didn’t Build\n\nI didn’t port shadcn/ui one-to-one. I extracted only the components I actually use. This is a practical toolkit, not a complete design system.\n\nIf you need modals, tooltips, date pickers, or complex form builders—those aren’t here yet. They might come later if I need them in my own projects.\n\nThe Rails Frontend Landscape\n\nThere’s no single UI kit that dominates Rails development. The community has fragmented across different approaches:\n\n\n ViewComponent and Phlex for Ruby-based component abstractions\n Inertia.js for React/Vue integration\n Various shadcn/ui ports with different philosophies\n\n\nEvil Martians has written extensively about modern frontend in Rails. Their work with ViewComponent and Inertia.js is excellent, but those approaches add dependencies I prefer to avoid.\n\nMaquina Components takes a different path: standard ERB, standard Tailwind, minimal JavaScript. If you’re building server-rendered Rails applications and want components that don’t require learning a new paradigm, this might work for you.\n\nAlternatives\n\nIf this approach doesn’t resonate, here are alternatives worth exploring:\n\n\n RailsUI — Premium UI templates and components\n RailsBlocks — Copy-paste components for Rails\n shadcn-rails — Another shadcn/ui port\n Inertia Rails Starter — React/Vue with Inertia\n\n\nGetting Started\n\nbundle add maquina_components\nrails generate maquina_components:install\n\n\nThe generator adds the engine CSS, theme variables, and a helper file for icon customization.\n\nBrowse the documentation for examples and API details. The test/dummy application in the repository shows all components with demo data.\n\nOpen Source\n\nMaquina Components is MIT licensed. The source is on GitHub.\n\nIf you try it and have feedback, I’d like to hear it. If this isn’t for you, that’s okay too. Rails is big enough for many approaches." }, { "id": "404", "title": "Page Not Found - Maquina", "collection": { "label": "pages", "name": "Posts" }, "categories": "", "tags": "", "url": "/404", "content": "Recuerd0\n \n \n Documentation\n \n \n Open Source\n \n \n Blog\n \n \n\n \n mobile-nav#toggle\">\n \n \n \n \n \n \n \n\n\n\n \n 404\n \n Page not found\n \n \n Sorry, we couldn't find the page you're looking for.\n \n \n \n Go home\n \n \n \n \n \n\n \n \n \n\n \n \n \n \n \n \n \n \n \n\n \n \n \n\n \n \n Products\n \n \n \n Recuerd0 \n \n \n \n \n \n Open Source\n \n \n \n Documentation \n \n \n \n \n Generators \n \n \n \n \n Components \n \n \n \n \n All Projects \n \n \n \n \n \n Company\n \n \n \n Blog \n \n \n \n \n \n Resources\n \n \n \n GitHub \n \n \n \n \n RubyGems \n \n \n \n \n \n \n \n\n \n \n \n \n \n \n \n\n \n \n \n © 2026 Maquina. Mario Alberto Chávez Cárdenas" }, { "id": "500", "title": "Server Error - Maquina", "collection": { "label": "pages", "name": "Posts" }, "categories": "", "tags": "", "url": "/500", "content": "Recuerd0\n \n \n Documentation\n \n \n Open Source\n \n \n Blog\n \n \n\n \n mobile-nav#toggle\">\n \n \n \n \n \n \n \n\n\n\n \n 500\n \n Something went wrong\n \n \n We're experiencing technical difficulties. Please try again later.\n \n \n \n Go home\n \n \n \n \n \n\n \n \n \n\n \n \n \n \n \n \n \n \n \n\n \n \n \n\n \n \n Products\n \n \n \n Recuerd0 \n \n \n \n \n \n Open Source\n \n \n \n Documentation \n \n \n \n \n Generators \n \n \n \n \n Components \n \n \n \n \n All Projects \n \n \n \n \n \n Company\n \n \n \n Blog \n \n \n \n \n \n Resources\n \n \n \n GitHub \n \n \n \n \n RubyGems \n \n \n \n \n \n \n \n\n \n \n \n \n \n \n \n\n \n \n \n © 2026 Maquina. Mario Alberto Chávez Cárdenas" }, { "id": "blog", "title": "Blog", "collection": { "label": "pages", "name": "Posts" }, "categories": "", "tags": "", "url": "/blog/", "content": "Featured\n \n\n \n \n \n\n \n \n Monday, August 10, 2026\n \n\n \n \n \n equipr: Cross-Agent Skill and MCP Server Manager\n \n \n\n \n equipr is out: one Go binary that installs skills, commands, and MCP servers into Claude Code, Codex, OpenCode, and Pi, with no plugin registration.\n \n\n \n \n\n \n Mario Alberto Chávez Cárdenas\n \n \n \n \n \n \n\n \n \n Tuesday, August 4, 2026\n \n\n \n \n \n Why I Removed execute_ruby from Rails MCP Server\n \n \n\n \n Rails MCP Server 2.0.0 removes the execute_ruby tool entirely, because the reasoning that justified it in 2025 stopped holding once agentic coding tools could run Ruby themselves.\n \n\n \n \n\n \n Mario Alberto Chávez Cárdenas\n \n \n \n \n \n \n\n \n \n Monday, August 3, 2026\n \n\n \n \n \n Rails MCP Server 1.6.0: Making "Read-Only" Actually Mean Read-Only\n \n \n\n \n Version 1.6.0 hardens the execute_ruby sandbox by closing real file-read bypasses, rolling back database writes, killing runaway processes, and asking before running dual-use code, plus manager-agnostic Ruby resolution and namespaced model fixes.\n \n\n \n \n\n \n Mario Alberto Chávez Cárdenas\n \n \n \n \n \n \n \n \n\n \n \n \n \n blog-filter#select\"\n aria-current=\"true\"\n class=\"\n rounded-full px-3 py-1 text-sm font-medium text-gray-600 ring-1\n ring-inset ring-gray-200 transition-colors hover:bg-gray-50\n aria-[current=true]:bg-gray-900 aria-[current=true]:text-white\n aria-[current=true]:ring-gray-900\n \"\n >\n All\n \n blog-filter#select\"\n aria-current=\"false\"\n class=\"\n rounded-full px-3 py-1 text-sm font-medium text-gray-600 ring-1\n ring-inset ring-gray-200 transition-colors hover:bg-gray-50\n aria-[current=true]:bg-gray-900 aria-[current=true]:text-white\n aria-[current=true]:ring-gray-900\n \"\n >\n Announcements\n \n blog-filter#select\"\n aria-current=\"false\"\n class=\"\n rounded-full px-3 py-1 text-sm font-medium text-gray-600 ring-1\n ring-inset ring-gray-200 transition-colors hover:bg-gray-50\n aria-[current=true]:bg-gray-900 aria-[current=true]:text-white\n aria-[current=true]:ring-gray-900\n \"\n >\n Release\n \n blog-filter#select\"\n aria-current=\"false\"\n class=\"\n rounded-full px-3 py-1 text-sm font-medium text-gray-600 ring-1\n ring-inset ring-gray-200 transition-colors hover:bg-gray-50\n aria-[current=true]:bg-gray-900 aria-[current=true]:text-white\n aria-[current=true]:ring-gray-900\n \"\n >\n Product\n \n blog-filter#select\"\n aria-current=\"false\"\n class=\"\n rounded-full px-3 py-1 text-sm font-medium text-gray-600 ring-1\n ring-inset ring-gray-200 transition-colors hover:bg-gray-50\n aria-[current=true]:bg-gray-900 aria-[current=true]:text-white\n aria-[current=true]:ring-gray-900\n \"\n >\n AI Tools\n \n blog-filter#select\"\n aria-current=\"false\"\n class=\"\n rounded-full px-3 py-1 text-sm font-medium text-gray-600 ring-1\n ring-inset ring-gray-200 transition-colors hover:bg-gray-50\n aria-[current=true]:bg-gray-900 aria-[current=true]:text-white\n aria-[current=true]:ring-gray-900\n \"\n >\n Tools\n \n \n\n \n \n \n\n \n \n RSS Feed\n \n \n\n \n \n \n \n Monday, August 10, 2026\n \n\n \n \n\n \n Mario Alberto Chávez Cárdenas\n \n \n \n\n \n \n equipr: Cross-Agent Skill and MCP Server Manager\n \n\n \n equipr is out: one Go binary that installs skills, commands, and MCP servers into Claude Code, Codex, OpenCode, and Pi, with no plugin registration.\n \n\n \n \n \n Read more\n \n \n \n \n \n \n \n \n \n \n Tuesday, August 4, 2026\n \n\n \n \n\n \n Mario Alberto Chávez Cárdenas\n \n \n \n\n \n \n Why I Removed execute_ruby from Rails MCP Server\n \n\n \n Rails MCP Server 2.0.0 removes the execute_ruby tool entirely, because the reasoning that justified it in 2025 stopped holding once agentic coding tools could run Ruby themselves.\n \n\n \n \n \n Read more\n \n \n \n \n \n \n \n \n \n \n Monday, August 3, 2026\n \n\n \n \n\n \n Mario Alberto Chávez Cárdenas\n \n \n \n\n \n \n Rails MCP Server 1.6.0: Making "Read-Only" Actually Mean Read-Only\n \n\n \n Version 1.6.0 hardens the execute_ruby sandbox by closing real file-read bypasses, rolling back database writes, killing runaway processes, and asking before running dual-use code, plus manager-agnostic Ruby resolution and namespaced model fixes.\n \n\n \n \n \n Read more\n \n \n \n \n \n \n \n \n \n \n Monday, July 27, 2026\n \n\n \n \n\n \n Mario Alberto Chávez Cárdenas\n \n \n \n\n \n \n Maquina Components 0.6.0: Themeable Beyond Color\n \n\n \n Radius, elevation and focus rings become design tokens in this Rails + Tailwind component library, engine CSS moves into @layer components, and utilities win.\n \n\n \n \n \n Read more\n \n \n \n \n \n \n \n \n \n \n Monday, July 20, 2026\n \n\n \n \n\n \n Mario Alberto Chávez Cárdenas\n \n \n \n\n \n \n Introducing Nexo: the harness for Ruby agents\n \n\n \n Nexo gives the RubyLLM ecosystem one front door: safe sandboxing, explicit permissions, and a real job primitive for agent runs. What it is, and why it exists.\n \n\n \n \n \n Read more\n \n \n \n \n \n \n \n \n \n \n Sunday, July 12, 2026\n \n\n \n \n\n \n Mario Alberto Chávez Cárdenas\n \n \n \n\n \n \n Maquina Components 0.5.0: Drawer and Scaffold Templates\n \n\n \n A Turbo-aware Drawer component and a scaffold_templates generator that styles rails g scaffold output — Maquina Components 0.5.0, built mostly by contributors.\n \n\n \n \n \n Read more\n \n \n \n \n \n \n \n \n \n \n Monday, July 6, 2026\n \n\n \n \n\n \n Mario Alberto Chávez Cárdenas\n \n \n \n\n \n \n Introducing Fragua: the harness already composed for Rails\n \n\n \n Fragua, an AI agent orchestrator for Rails — turn an idea into a shipped app through research, plan, spec, and ship, with agents that respect the craft. Now taking private beta requests.\n \n\n \n \n \n Read more\n \n \n \n \n \n \n \n \n \n \n Thursday, June 11, 2026\n \n\n \n \n\n \n Mario Alberto Chávez Cárdenas\n \n \n \n\n \n \n recuerd0 Now Has an MCP Server — and a Better Look\n \n\n \n recuerd0 now has a remote MCP server with OAuth 2.1, a markdown editor based on 37signals' House MD, and a refined UI built on Maquina Components.\n \n\n \n \n \n Read more\n \n \n \n \n \n \n \n \n \n \n Tuesday, April 7, 2026\n \n\n \n \n\n \n Mario Alberto Chávez Cárdenas\n \n \n \n\n \n \n Recuerd0 Now Reads Like a Filesystem\n \n\n \n Recuerd0's API now lets AI agents grep, glob, and read memories in line ranges — the same primitives Claude Code already uses. Plus categories, links, and caching.\n \n\n \n \n \n Read more\n \n \n \n \n \n \n \n \n \n \n Monday, March 23, 2026\n \n\n \n \n\n \n Mario Alberto Chávez Cárdenas\n \n \n \n\n \n \n MVP Creator: From Idea to Documents in Three Prompts\n \n\n \n Use MVP Creator, a Claude Code plugin, to generate research reports, business plans, brand guides, and technical specs for new Rails projects. Three prompts, six documents.\n \n\n \n \n \n Read more\n \n \n \n \n \n \n \n \n \n \n Friday, March 13, 2026\n \n\n \n \n\n \n Mario Alberto Chávez Cárdenas\n \n \n \n\n \n \n Maquina Generators: From rails new to Production-Ready\n \n\n \n Rails generators for authentication, job queues, error tracking, and security. No runtime dependency — generate once, own the code forever.\n \n\n \n \n \n Read more\n \n \n \n \n \n \n \n \n \n \n Saturday, February 21, 2026\n \n\n \n \n\n \n Mario Alberto Chávez Cárdenas\n \n \n \n\n \n \n Recuerd0 Source Code Is Now Available\n \n\n \n The Recuerd0 source code is now on GitHub. Built with Rails 8, SQLite, and Hotwire — here's a look under the hood.\n \n\n \n \n \n Read more\n \n \n \n \n \n \n \n \n \n \n Sunday, February 15, 2026\n \n\n \n \n\n \n Mario Alberto Chávez Cárdenas\n \n \n \n\n \n \n Announcing Recuerd0: A Knowledge Base for AI Tool Context\n \n\n \n Versioned knowledge base for AI coding tools. Curate project context once, serve it via REST API to Claude Code, Cursor, and ChatGPT. SaaS or self-hosted.\n \n\n \n \n \n Read more\n \n \n \n \n \n \n \n \n \n \n Friday, February 13, 2026\n \n\n \n \n\n \n Mario Alberto Chávez Cárdenas\n \n \n \n\n \n \n Maquina Components 0.4.0: Taming Turbo\n \n\n \n Turbo Drive and Morph compatibility fixes for sidebars, plus a Rails partial rendering fix for block content in 9 components.\n \n\n \n \n \n Read more\n \n \n \n \n \n \n \n \n \n \n Friday, January 23, 2026\n \n\n \n \n\n \n Mario Alberto Chávez Cárdenas\n \n \n \n\n \n \n Maquina 0.3.1: Calendar, Date Picker & Claude Code Skills\n \n\n \n Maquina Components 0.3.1 adds Calendar and Date Picker for Rails. Plus Claude Code skills for consistent UI generation and Rails code simplification.\n \n\n \n \n \n Read more\n \n \n \n \n \n \n \n \n \n \n Thursday, January 8, 2026\n \n\n \n \n\n \n Mario Alberto Chávez Cárdenas\n \n \n \n\n \n \n Claude Skill for Maquina Components\n \n\n \n Teach Claude how to build consistent UIs in Rails applications using maquina_components. A skill for AI-assisted development.\n \n\n \n \n \n Read more\n \n \n \n \n \n \n \n \n \n \n Wednesday, January 7, 2026\n \n\n \n \n\n \n Mario Alberto Chávez Cárdenas\n \n \n \n\n \n \n Maquina Components 0.3.0: Combobox and Toast\n \n\n \n Two new interactive components for Rails applications. Searchable dropdowns and non-intrusive notifications.\n \n\n \n \n \n Read more\n \n \n \n \n \n \n \n \n \n \n Monday, December 29, 2025\n \n\n \n \n\n \n Mario Alberto Chávez Cárdenas\n \n \n \n\n \n \n Rails MCP Server 1.5.0: Security Hardening and Sandboxed Environment Support\n \n\n \n Version 1.5.0 brings comprehensive security improvements from community contributor hellvinz, plus support for sandboxed AI agents like GitHub Copilot and Claude Code.\n \n\n \n \n \n Read more\n \n \n \n \n \n \n \n \n \n \n Tuesday, December 16, 2025\n \n\n \n \n\n \n Mario Alberto Chávez Cárdenas\n \n \n \n\n \n \n Announcing Maquina Components: Opinionated Ul for Rails Applications\n \n\n \n Production-ready ERB partials styled with Tailwind CSS 4.0. Extracted from real applications.\n \n\n \n \n \n Read more\n \n \n \n \n \n \n \n \n\n \n No posts in this category yet.\n blog-filter#select\"\n class=\"font-medium text-gray-950 underline underline-offset-4 hover:text-gray-700\"\n >\n View all posts\n ." }, { "id": "company", "title": "About Maquina — Open Source Rails Tools & Philosophy", "collection": { "label": "pages", "name": "Posts" }, "categories": "", "tags": "", "url": "/company/", "content": "Recuerd0\n \n \n Documentation\n \n \n Open Source\n \n \n Blog\n \n \n\n \n mobile-nav#toggle\"\n >\n \n \n \n \n \n \n \n \n \n\n\n \n \n \n \n \n Empowering Rails developers everywhere.\n \n \n We're building the tools that make multi-tenant Rails applications accessible to every developer.\n \n\n \n \n Our mission\n \n At Maquina, we believe that building multi-tenant applications shouldn't require months of boilerplate code or expensive SaaS subscriptions. Every Rails developer should have access to professional-grade tools for building modern applications.\n \n \n Maquina was born from years of experience building production Rails applications. We've distilled the best patterns and practices into a single, cohesive framework that gets out of your way and lets you focus on what makes your application unique.\n \n \n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n The Numbers\n \n \n \n License\n MIT\n \n \n Open Source\n 100%\n \n \n Rails Version\n 7+\n \n \n Ruby Version\n 3.2+\n \n \n \n \n \n \n\n \n \n Our Story\n \n Built by developers, for developers.\n \n \n Maquina is the result of years of building Rails applications and learning what patterns work best.\n \n\n \n \n \n After building dozens of multi-tenant Rails applications, we noticed the same patterns emerging over and over: authentication flows, organization management, role-based access control, and UI components that needed to be rebuilt for every project.\n \n \n We decided to distill these patterns into a single, cohesive framework. Maquina follows Rails conventions, embraces Hotwire for modern interactivity without JavaScript complexity, and provides beautiful UI components built with ViewComponent and Tailwind CSS.\n \n \n \n Read the docs\n \n \n \n \n \n \n \n \n \n Open Source on GitHub\n \n \n \n \n \n \n\n \n \n Our Values\n \n Principles that guide us.\n \n \n Everything we build is guided by these core principles.\n \n\n Core Principles\n \n\n \n \n \n \n \n \n \n Convention over configuration\n \n Follow Rails conventions whenever possible. Sensible defaults mean less code to write and maintain. We believe the best code is the code you don't have to write.\n \n \n \n \n \n \n \n \n Security first\n \n Security is not an afterthought. Every feature is designed with security best practices from the start. Authentication, authorization, and data isolation are core to the framework.\n \n \n \n \n \n \n \n \n Documentation matters\n \n Great software deserves great documentation. We invest heavily in guides, examples, and API references. If it's not documented, it doesn't exist.\n \n \n \n \n \n \n \n \n Community driven\n \n Built by the community, for the community. Every contribution matters and every voice is heard. We're committed to building in the open with transparency.\n \n \n \n \n \n\n \n \n Get Involved\n \n Join our open source community.\n \n \n We welcome contributions of all kinds. Whether it's code, documentation, or feedback, every contribution helps make Maquina better for everyone.\n \n\n \n \n Ways to contribute\n \n \n \n \n \n \n \n \n Contribution type\n Link\n \n \n \n \n \n \n Code\n \n \n \n \n Submit a pull request\n \n \n View\n \n \n \n \n Report a bug\n \n \n View\n \n \n \n \n \n \n Community\n \n \n \n \n Join the discussion\n \n \n View\n \n \n \n \n Star on GitHub\n \n \n View\n \n \n \n \n \n \n \n\n \n \n \n \n \n Open source is not just about code. It's about building a community of developers who share knowledge and help each other grow.\n \n \n \n The Maquina Team\n \n \n Open Source Contributors\n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n \n \n \n \n\n \n \n \n\n \n \n Products\n \n \n \n Recuerd0 \n \n \n \n \n \n Open Source\n \n \n \n Documentation \n \n \n \n \n Generators \n \n \n \n \n Components \n \n \n \n \n All Projects \n \n \n \n \n \n Company\n \n \n \n Blog \n \n \n \n \n \n Resources\n \n \n \n GitHub \n \n \n \n \n RubyGems \n \n \n \n \n \n \n \n\n \n \n \n \n \n \n \n\n \n \n \n © 2026 Maquina. Mario Alberto Chávez Cárdenas" }, { "id": "documentation-ai-tools-better-stimulus", "title": "Better Stimulus", "collection": { "label": "pages", "name": "Posts" }, "categories": "", "tags": "", "url": "/documentation/ai-tools/better-stimulus/", "content": "A Claude Code plugin that applies opinionated StimulusJS best practices sourced from betterstimulus.com. Use it whenever writing, reviewing, debugging, or refactoring Stimulus controllers.\n\n\n\nWhat Is This?\n\nA Claude Code skill that:\n\n\n Writes Stimulus controllers that follow the Values API, Classes API, and Outlets patterns\n Reviews existing controllers against a concrete best-practices checklist\n Refactors anti-patterns such as hardcoded selectors, instance-variable state, and manual addEventListener calls\n Applies SOLID principles adapted to Stimulus (Single Responsibility, Open-Closed, Dependency Inversion)\n Integrates with Turbo — teardown before caching, restoring DOM state, form interception\n\n\nThe skill is triggered by mentions of data-controller, data-action, data-target, data-values, outlets, lifecycle callbacks, Hotwire patterns, or Turbo-and-Stimulus integration.\n\n\n\nQuick Start\n\n1. Add the Marketplace\n\n/plugin marketplace add maquina-app/rails-claude-code\n\n\n2. Install the Plugin\n\n/plugin install better-stimulus@maquina\n\n\n3. Ask for a Review or Refactor\n\n> Review my dropdown_controller.js against Stimulus best practices\n> Refactor this controller to use the Values API instead of instance variables\n> Write a Stimulus controller that toggles a panel with late binding\n\n\n\n\nWhat It Enforces\n\n\n \n \n Area\n Best Practice\n \n \n \n \n State\n Use the Values API as the single source of truth, not instance variables\n \n \n CSS classes\n Store in static classes, never hardcode strings\n \n \n Events\n Declare in data-action markup, not addEventListener in connect()\n \n \n Dependencies\n Late binding via Values / Classes / dataset — no hardcoded selectors\n \n \n Composition\n Prefer mixins for roles, inheritance only for specializations\n \n \n Inter-controller\n Outlets for direct calls, custom events for broadcast\n \n \n Third-party libs\n Initialize in connect(), destroy in disconnect()\n \n \n Turbo\n Implement teardown() wired to turbo:before-cache when mutating DOM\n \n \n SRP\n Split controllers that act on both this.element and targets\n \n \n\n\nA full pre-commit checklist ships with the plugin so Claude can verify any new controller before handing it back.\n\n\n\nUsage Examples\n\nReview an Existing Controller\n\n> Review app/javascript/controllers/modal_controller.js\n\n\nThe skill checks for state in instance variables, hardcoded CSS classes, manual event listeners, mixed element/target responsibilities, and missing Turbo teardown.\n\nRefactor Toward the Values API\n\n> Refactor this controller so state lives in values, not this.open\n\n\nThe skill moves state into static values, adds a *ValueChanged callback, and updates markup to include the new data attributes.\n\nWrite a New Controller with Late Binding\n\n> Write a toggle controller that uses data-toggle-active-class for styling\n\n\nThe skill produces a controller that reads its active class via the Classes API so the same code works anywhere the markup declares a class.\n\nApply Hotwire Patterns\n\n> This controller mutates the DOM — add a teardown for Turbo caching\n\n\nThe skill adds a teardown() method and wires it to turbo:before-cache at the application level.\n\n\n\nReference Material\n\nThe plugin ships with two reference files the skill consults on demand:\n\n\n \n \n Reference\n Purpose\n \n \n \n \n references/cookbook.md\n Copy-paste-ready controllers: faceted search, refresh-when-visible, auto sort, dark mode, radio dropdown\n \n \n references/solid.md\n SOLID principles adapted to Stimulus with examples and rationale\n \n \n\n\n\n\nPackage Contents\n\nbetter-stimulus/\n└── skills/better-stimulus/\n ├── SKILL.md # Main skill\n └── references/\n ├── cookbook.md # Ready-to-use controller patterns\n ├── inter-controller.md # Outlets, callbacks, custom events\n ├── error-handling.md # Global error handler\n └── solid.md # SOLID principles for Stimulus\n\n\n\n\nTeam Installation\n\nAdd to your project’s .claude/settings.json:\n\n{\n \"extraKnownMarketplaces\": {\n \"maquina\": {\n \"source\": {\n \"source\": \"github\",\n \"repo\": \"maquina-app/rails-claude-code\"\n }\n }\n },\n \"enabledPlugins\": [\n \"better-stimulus@maquina\"\n ]\n}\n\n\n\n\nNext Steps\n\n\n \n \n GitHub Repository\n \n \n View source code and contribute.\n \n \n\n \n \n betterstimulus.com\n \n \n The original collection of Stimulus best practices.\n \n \n\n \n \n Maquina UI Standards\n \n \n Pair with component-level UI guidance." }, { "id": "documentation-ai-tools-hotwire-patterns", "title": "Hotwire Patterns", "collection": { "label": "pages", "name": "Posts" }, "categories": "", "tags": "", "url": "/documentation/ai-tools/hotwire-patterns/", "content": "A Claude Code skill that gives Claude internals-informed mental models for building and debugging Hotwire applications. Core philosophy: enhance the browser, don’t reinvent it — start by imagining a JS-free, plain-HTML version of every feature, then compose the pages into an integrated UI with Turbo. HTML is the source of truth for state, everywhere.\n\nIt complements Better Stimulus (the authority for writing controllers) and Rails Hotwire Driver (which exercises a running Hotwire app from the terminal). This skill is the knowledge layer — how Turbo and Stimulus actually work under the hood.\n\n\n\nWhat Is This?\n\nA Claude Code skill that helps Claude reason about:\n\n\n Turbo Drive, Frames, and Streams — how each observer scopes an update, and the classic frame-id mismatch that produces “Content missing”\n Morphing — the idiomorph algorithm, exactly when a morph runs, and how to exclude elements\n Turbo Cache — snapshot mechanics, preview flashing, turbo-permanent, and cache-control\n Broadcasting — ActionCable stream sources, the ~0.5s debounce, and request-id dedup\n Stimulus design — callbacks over connect, composition via events vs outlets\n Hotwire Native — Path Configuration, Bridge Components, and the native-adapter mental model\n Testing & debugging — system-test flakiness, collaborative tests, legacy migration, and internals-informed debugging\n\n\nIt is delivered as a skill (a knowledge module plus focused reference files), not an autonomous agent. Claude reads SKILL.md for the decision frameworks and pulls in a reference only when a specific branch needs it.\n\n\n\nThe Escalation Ladder\n\nThe skill’s central idea: Hotwire is a cost/benefit dial, not a single approach. Choose the cheapest tool that works, and escalate only when the previous rung stops being a good tradeoff:\n\n\n Turbo Drive + Morphing refreshes — re-render everything server-side; fastest to build.\n Turbo Frames — decompose the page; localize updates without touching the rest.\n Turbo Stream actions — surgical DOM updates; more precise, more maintenance cost.\n Stimulus — small client-side behavior where a server round-trip makes no sense.\n Island of a reactive framework or API calls — only for genuinely high-interactivity widgets (maps, editors).\n\n\nDifferent parts of one app can sit on different rungs; it all composes.\n\n\n\nQuick Start\n\n1. Add the Marketplace\n\n/plugin marketplace add maquina-app/rails-claude-code\n\n\n2. Install the Plugin\n\n/plugin install hotwire-patterns@maquina\n\n\n3. Ask About Hotwire\n\nThe skill triggers on Hotwire design decisions and symptoms:\n\n> Why does morphing wipe my form?\n> My Turbo Stream broadcast isn't arriving\n> This system test is flaky\n> How do I add Turbo to a legacy app?\n> Wrap my app with Hotwire Native\n\n\n\n\nWhat It Covers\n\nThe main SKILL.md holds the decision frameworks and per-topic essentials; each deep dive lives in a reference file Claude loads on demand.\n\n\n \n \n Topic\n Reference\n Highlights\n \n \n \n \n Turbo internals\n (in SKILL.md)\n Drive/Frames/Streams observers, _top escaping, lazy frames, the frame-id mismatch\n \n \n Morphing\n morphing.md\n idiomorph algorithm, when morph actually runs, scoped exclusion via turbo:before-morph-element\n \n \n Turbo Cache\n (in SKILL.md)\n snapshot cloneNode, preview flashing, data-turbo-temporary, cache-control meta\n \n \n Broadcasting\n (in SKILL.md)\n signed stream names, the background-job debounce, originating-client dedup\n \n \n Stimulus design\n stimulus.md\n callbacks over connect, events vs outlets, the dynamic-forms server-render pattern\n \n \n Hotwire Native\n hotwire-native.md\n native adapter, Path Configuration, Bridge Components, publishing\n \n \n Testing\n testing-and-legacy.md\n flakiness (assert stable state), multi-session broadcast tests, gradual Turbo adoption\n \n \n Debugging\n debugging.md\n unminify Turbo, DOM break-on breakpoints, source landmarks, ActionCable filtering\n \n \n\n\n\n\nCustom Stream Actions\n\nA recurring theme: the correct UI update is only known server-side after processing. Custom Turbo Stream actions are the sanctioned way to run backend-driven browser behavior with a constrained, maintainable vocabulary — and they keep you CSP-compatible (no unsafe-inline):\n\nTurbo.StreamActions.log = function () {\n console.log(this.getAttribute(\"message\"))\n}\n\n\nPair with a Ruby helper module included into Turbo::Streams::TagBuilder. Prefer small, app-specific actions over dropping in large libraries.\n\n\n\nPackage Contents\n\nhotwire-patterns/\n└── skills/hotwire-patterns/\n ├── SKILL.md # Overview + decision frameworks + per-topic essentials\n └── references/\n ├── morphing.md # idiomorph algorithm and gotchas\n ├── stimulus.md # reusable, composable controller design\n ├── hotwire-native.md # iOS/Android wrapping\n ├── testing-and-legacy.md # system tests + gradual Turbo adoption\n └── debugging.md # internals-informed debugging\n\n\n\n\nTeam Installation\n\nAdd to your project’s .claude/settings.json:\n\n{\n \"extraKnownMarketplaces\": {\n \"maquina\": {\n \"source\": {\n \"source\": \"github\",\n \"repo\": \"maquina-app/rails-claude-code\"\n }\n }\n },\n \"enabledPlugins\": [\n \"hotwire-patterns@maquina\"\n ]\n}\n\n\n\n\nNext Steps\n\n\n \n \n GitHub Repository\n \n \n View source code and contribute.\n \n \n\n \n \n Better Stimulus\n \n \n Write the Stimulus controllers behind your Hotwire UI.\n \n \n\n \n \n Rails Hotwire Driver\n \n \n Exercise a running Hotwire app from the terminal." }, { "id": "documentation-ai-tools", "title": "AI Tools", "collection": { "label": "pages", "name": "Posts" }, "categories": "", "tags": "", "url": "/documentation/ai-tools/", "content": "MCP servers, Claude Code plugins, and AI integrations that connect LLMs to your Rails development workflow. Analyze code, access documentation, and coordinate changes across tools.\n\n\n\nAvailable Tools\n\nMCP Servers\n\n\n \n \n Rails MCP Server\n \n \n Let LLMs introspect models, routes, schemas, and files through dedicated analyzers.\n \n \n\n \n \n Neovim MCP Server\n \n \n Read and update Neovim buffers from AI assistants.\n \n \n\n\nClaude Code Plugins\n\n\n \n \n Rails Simplifier\n \n \n Code simplification following 37signals patterns and One Person Framework.\n \n \n\n \n \n Rails Upgrade Assistant\n \n \n Generate upgrade guides for Rails 6.0 through 8.1.\n \n \n\n \n \n Maquina UI Standards\n \n \n Build consistent UIs with maquina_components.\n \n \n\n \n \n MVP Creator\n \n \n Research, plan, and document MVPs for Rails applications.\n \n \n\n \n \n Better Stimulus\n \n \n Opinionated StimulusJS best practices from betterstimulus.com.\n \n \n\n \n \n Spec-Driven Development\n \n \n Shape features into specs and hand off to Claude Code.\n \n \n\n \n \n Rails Security Auditor\n \n \n Severity-grouped security audit reports for Rails 8.0–8.2.\n \n \n\n \n \n Rails Hotwire Driver\n \n \n Drive a running local Rails dev server from the terminal.\n \n \n\n \n \n Hotwire Patterns\n \n \n Deep Hotwire internals, decision frameworks, and debugging.\n \n \n\n\n\n\nInstalling These\n\nEvery tool on this page is installed by hand: a gem, a marketplace command, a config file edit. equipr does it mechanically instead. Point it at a marketplace or an Agent Plugins source and it places each skill, command, and MCP server where each agent expects it, across Claude Code, Codex CLI, OpenCode, and Pi.\n\nequipr add https://github.com/coreyhaines31/marketingskills\nequipr install marketingskills/marketing-skills:seo-audit\n\n\nView equipr Documentation\n\n\n\nWhat is MCP?\n\nThe Model Context Protocol (MCP) is a standardized way for AI models to interact with their environment. It defines how models request and use tools, access resources, and maintain context.\n\nMCP servers expose tools that AI assistants can call:\n\n# Example: AI assistant analyzes a Rails model\nexecute_tool(tool_name: \"analyze_models\", params: { model_name: \"User\" })\n\n\nSupported Clients\n\n\n Claude Desktop\n Any MCP-compatible client\n Custom integrations via HTTP/SSE mode\n\n\n\n\nWhat are Claude Code Plugins?\n\nClaude Code plugins extend Claude’s capabilities within your development environment. They can be installed from marketplaces and provide:\n\n\n Agents — Specialized AI assistants for specific tasks\n Skills — Knowledge modules that teach Claude project-specific patterns\n Commands — Custom slash commands for workflows\n\n\nInstalling Plugins\n\n# Add the marketplace\n/plugin marketplace add maquina-app/rails-claude-code\n\n# Install a plugin\n/plugin install rails-simplifier@maquina\n\n\n\n\nGetting Started\n\nWith MCP Servers\n\n1. Install an MCP Server\n\ngem install rails-mcp-server\n\n\n2. Configure Your Client\n\nFor Claude Desktop, add to claude_desktop_config.json:\n\n{\n \"mcpServers\": {\n \"railsMcpServer\": {\n \"command\": \"rails-mcp-server\"\n }\n }\n}\n\n\n3. Start Using Tools\n\nIn Claude Desktop, the MCP server tools become available automatically. Ask Claude to:\n\n\n “Analyze the User model in my Rails project”\n “Show me the routes for the orders controller”\n “What’s the database schema for the products table?”\n\n\nWith Claude Code Plugins\n\n1. Add the Marketplace\n\n/plugin marketplace add maquina-app/rails-claude-code\n\n\n2. Install Plugins\n\n/plugin install rails-simplifier@maquina\n/plugin install rails-upgrade-assistant@maquina\n/plugin install maquina-ui-standards@maquina\n/plugin install mvp-creator@maquina\n/plugin install better-stimulus@maquina\n/plugin install spec-driven-development@maquina\n/plugin install rails-security-auditor@maquina\n/plugin install rails-hotwire-driver@maquina\n/plugin install hotwire-patterns@maquina\n\n\n3. Use the Plugins\n\n> Simplify the recent changes to the bookings controller\n> Upgrade my Rails app to 8.1\n> Create a users index view with maquina components\n\n\n\n\nArchitecture\n\nMCP Communication\n\nMCP servers communicate via JSON-RPC 2.0:\n\n\n \n \n Mode\n Use Case\n \n \n \n \n STDIO\n Direct integration with Claude Desktop\n \n \n HTTP/SSE\n Web-based clients, remote access\n \n \n\n\nPlugin Structure\n\nClaude Code plugins follow a standard structure:\n\nplugin-name/\n├── agents/ # AI agent definitions\n│ └── agent.md\n├── skills/ # Knowledge modules\n│ └── SKILL.md\n├── commands/ # Custom slash commands\n│ └── command.md\n└── references/ # Documentation\n └── *.md\n\n\n\n\nTool Reference\n\n\n \n \n Tool\n Type\n Purpose\n \n \n \n \n Rails MCP Server\n MCP Server\n Code analysis and Ruby execution\n \n \n Neovim MCP Server\n MCP Server\n Editor buffer coordination\n \n \n Rails Simplifier\n Plugin\n Code simplification with 37signals patterns\n \n \n Rails Upgrade Assistant\n Plugin\n Rails 6.0–8.1 upgrade planning\n \n \n Maquina UI Standards\n Plugin\n UI component generation\n \n \n MVP Creator\n Plugin\n MVP research, planning, and documentation\n \n \n Better Stimulus\n Plugin\n StimulusJS best practices and refactoring\n \n \n Spec-Driven Development\n Plugin\n Feature specs, task breakdown, and progress tracking\n \n \n Rails Security Auditor\n Plugin\n Rails 8.0–8.2 security audits with severity grouping\n \n \n Rails Hotwire Driver\n Skill\n Drive a running local Rails dev server from the terminal\n \n \n Hotwire Patterns\n Skill\n Deep Hotwire internals, decision frameworks, and debugging\n \n \n\n\n\n\nTeam Installation\n\nFor consistent tooling across your team, add to .claude/settings.json:\n\n{\n \"extraKnownMarketplaces\": {\n \"maquina\": {\n \"source\": {\n \"source\": \"github\",\n \"repo\": \"maquina-app/rails-claude-code\"\n }\n }\n },\n \"enabledPlugins\": [\n \"rails-simplifier@maquina\",\n \"rails-upgrade-assistant@maquina\",\n \"maquina-ui-standards@maquina\",\n \"mvp-creator@maquina\",\n \"better-stimulus@maquina\",\n \"spec-driven-development@maquina\",\n \"rails-security-auditor@maquina\",\n \"rails-hotwire-driver@maquina\",\n \"hotwire-patterns@maquina\"\n ]\n}\n\n\nCommit this file to your repository. Team members get the same plugins automatically." }, { "id": "documentation-ai-tools-maquina-ui-standards", "title": "Maquina UI Standards", "collection": { "label": "pages", "name": "Posts" }, "categories": "", "tags": "", "url": "/documentation/ai-tools/maquina-ui-standards/", "content": "A Claude Code plugin that teaches Claude how to build UIs with maquina_components — ERB partials styled with Tailwind CSS 4 and data attributes, inspired by shadcn/ui.\n\n\n\nWhat Is This?\n\nA Claude Code skill that provides:\n\n\n Component catalog — All 15+ components with ERB examples\n Form patterns — Validation, error handling, inline layouts\n Layout patterns — Sidebar navigation, page structure\n Turbo integration — Frames, Streams, component updates\n Spec checklist — Review criteria for UI quality\n\n\nWhen installed, Claude generates code that matches your component conventions without back-and-forth corrections.\n\n\n\nThe Problem It Solves\n\nWithout the skill, asking Claude to build a view results in generic Rails patterns:\n\n<div class=\"card\">\n <h2><%= @user.name %></h2>\n</div>\n\n\nWith the skill, Claude uses your actual components:\n\n<%= render \"components/card\" do %>\n <%= render \"components/card/header\" do %>\n <%= render \"components/card/title\", text: @user.name %>\n <% end %>\n<% end %>\n\n\nThe skill eliminates the “use the card partial, not a div” corrections that slow down AI-assisted development.\n\n\n\nQuick Start\n\n1. Add the Marketplace\n\n/plugin marketplace add maquina-app/rails-claude-code\n\n\n2. Install the Plugin\n\n/plugin install maquina-ui-standards@maquina\n\n\n3. Start Building\n\n> Create the users index view with a table showing name, email, and status\n\n\n\n\nRequirements\n\nThe maquina_components gem must be installed in your Rails application:\n\nbundle add maquina_components\nrails generate maquina_components:install\n\n\n\n\nWhat It Provides\n\n\n \n \n Reference\n Purpose\n \n \n \n \n Component catalog\n All available components with ERB examples\n \n \n Form patterns\n Validation states, error handling, inline layouts\n \n \n Layout patterns\n Sidebar navigation, page headers, content areas\n \n \n Turbo integration\n Frames, Streams, and component updates\n \n \n Spec checklist\n Accessibility and consistency review criteria\n \n \n Helpers reference\n Ruby helpers provided by maquina_components\n \n \n Stimulus controllers\n Controllers shipped with the component library\n \n \n Installation guide\n Setup for existing and new Rails applications\n \n \n\n\nAs of v0.5.0 the plugin ships as a model-invoked skill instead of a subagent: Claude loads the standards into the same conversation where your feature is being built — with your models, controllers, and spec in context — rather than delegating view work to an isolated agent. The trigger works the same way; the skill activates when UI work starts. This release also syncs the references with maquina_components 0.5.x: the Drawer component, the Stats grid, vocabulary aliases (:destructive/:error, size :default), text:/content: leaf parameters, and the ability to attach your own Stimulus controllers to any component (data-controller values concatenate instead of being replaced).\n\n\n On maquina_components 0.7.1. The bundled references are verified against 0.7.1: the token layer, engine CSS in @layer components, the drawer and sidebar parts, and the fact that utility classes passed through css_classes: now win where they used to be swallowed. They also carry the 0.7.0 changes — the collapse_after deprecation on responsive_breadcrumbs, the icon-override boundary, sidebar inert, and the rule never to transition outline-color in a component of your own — and the 0.7.1 ones: form examples now set aria-invalid, which is what drives the error state since the engine stopped inferring it from :invalid. Theming and Upgrading remain the canonical source if the two ever disagree.\n\n\n\n\nUsage Examples\n\nCreate a View\n\n> Create the users index view with a table showing name, email, and status\n\n\nClaude generates:\n\n<%= render \"components/page_header\", title: \"Users\", description: \"Manage team members\" %>\n\n<%= render \"components/card\" do %>\n <%= render \"components/card/content\" do %>\n <table data-component=\"table\">\n <thead>\n <tr>\n <th>Name</th>\n <th>Email</th>\n <th>Status</th>\n </tr>\n </thead>\n <tbody>\n <% @users.each do |user| %>\n <tr>\n <td><%= user.name %></td>\n <td><%= user.email %></td>\n <td>\n <%= render \"components/badge\",\n variant: user.active? ? :success : :secondary do %>\n <%= user.active? ? \"Active\" : \"Inactive\" %>\n <% end %>\n </td>\n </tr>\n <% end %>\n </tbody>\n </table>\n <% end %>\n<% end %>\n\n\nImplement a Form\n\n> Implement the project form with name, description, and a framework combobox\n\n\nClaude generates:\n\n<%= form_with model: @project do |form| %>\n <div class=\"space-y-4\">\n <div>\n <%= form.label :name %>\n <%= form.text_field :name, data: { component: \"input\" } %>\n </div>\n\n <div>\n <%= form.label :description %>\n <%= form.text_area :description, data: { component: \"textarea\" }, rows: 4 %>\n </div>\n\n <div>\n <%= form.label :framework_id %>\n <%= combobox_simple name: \"project[framework_id]\",\n value: @project.framework_id,\n options: @frameworks.map { |f| { value: f.id, label: f.name } },\n placeholder: \"Select a framework\" %>\n </div>\n\n <div class=\"flex justify-end gap-2\">\n <%= link_to \"Cancel\", projects_path,\n data: { component: \"button\", variant: \"outline\" } %>\n <%= form.submit \"Save\",\n data: { component: \"button\", variant: \"primary\" } %>\n </div>\n </div>\n<% end %>\n\n\nReview Existing Code\n\n> Review this view against the maquina UI standards and suggest improvements\n\n\nClaude checks for:\n\n Proper component usage instead of raw HTML\n Correct data attributes on form fields\n Accessibility attributes\n Consistent spacing and layout patterns\n Turbo Frame and Stream integration\n\n\n\n\nComponent Patterns\n\nPartial Components\n\nComponents rendered as partials with strict locals:\n\n<%# Card with header and content %>\n<%= render \"components/card\" do %>\n <%= render \"components/card/header\" do %>\n <%= render \"components/card/title\", text: \"Appointments\" %>\n <%= render \"components/card/description\", text: \"Manage your schedule\" %>\n <% end %>\n <%= render \"components/card/content\" do %>\n <!-- Content here -->\n <% end %>\n<% end %>\n\n\nData Attribute Components\n\nForm elements and buttons use data attributes for styling:\n\n<%# Text input %>\n<%= form.text_field :name, data: { component: \"input\" } %>\n\n<%# Button %>\n<%= link_to \"Edit\", edit_path,\n data: { component: \"button\", variant: \"outline\", size: \"sm\" } %>\n\n<%# Badge %>\n<%= render \"components/badge\", variant: :success do %>\n Active\n<% end %>\n\n\nLayout Patterns\n\n<%# Sidebar layout %>\n<%= render \"components/sidebar/provider\", state: sidebar_state do %>\n <%= render \"components/sidebar\" do %>\n <%= render \"components/sidebar/header\" do %>\n <!-- Logo -->\n <% end %>\n <%= render \"components/sidebar/content\" do %>\n <%= render \"components/sidebar/group\", title: \"Navigation\" do %>\n <%= render \"components/sidebar/menu\" do %>\n <%= render \"components/sidebar/menu_item\" do %>\n <%= render \"components/sidebar/menu_button\",\n url: dashboard_path,\n icon_name: :home,\n title: \"Dashboard\",\n active: current_page?(dashboard_path) %>\n <% end %>\n <% end %>\n <% end %>\n <% end %>\n <% end %>\n\n <%= render \"components/sidebar/inset\" do %>\n <%= yield %>\n <% end %>\n<% end %>\n\n\n\n\nPackage Contents\n\nmaquina-ui-standards/\n├── skills/ui/SKILL.md # Model-invoked skill\n├── QUICKSTART.md # Quick reference for humans\n└── references/\n ├── component-catalog.md # All available components\n ├── form-patterns.md # Validation, error handling\n ├── layout-patterns.md # Pages, dashboards\n ├── turbo-integration.md # Frames, streams\n ├── spec-checklist.md # Accessibility, consistency\n ├── helpers-reference.md # Ruby helpers provided by maquina_components\n ├── stimulus-controllers.md # Controllers shipped with the library\n └── installation-guide.md # Setup for existing and new Rails apps\n\n\n\n\nTeam Installation\n\nAdd to your project’s .claude/settings.json:\n\n{\n \"extraKnownMarketplaces\": {\n \"maquina\": {\n \"source\": {\n \"source\": \"github\",\n \"repo\": \"maquina-app/rails-claude-code\"\n }\n }\n },\n \"enabledPlugins\": [\n \"maquina-ui-standards@maquina\"\n ]\n}\n\n\n\n\nAlternative: Claude Skill Installation\n\nIf you prefer using Claude Skills instead of the plugin system, copy the skill to your project:\n\nmkdir -p .claude/skills\n# Copy from the rails-claude-code repository\ncp -r maquina-ui-standards/skills/ui .claude/skills/maquina-ui-standards\ncp -r maquina-ui-standards/references .claude/skills/\n\n\nThen reference it in your CLAUDE.md:\n\n## UI Components\n\nThis project uses maquina_components for UI. Before implementing views,\nforms, or interactive components, read the UI standards skill:\n\n.claude/skills/maquina-ui-standards/SKILL.md\n\nAlways consult the skill when:\n- Creating or modifying views\n- Implementing forms\n- Adding interactive components\n- Building layouts with sidebar/header patterns\n- Working with Turbo Streams that update UI\n\n\n\n\nNext Steps\n\n\n \n \n GitHub Repository\n \n \n View source code and contribute.\n \n \n\n \n \n Component Documentation\n \n \n Browse all maquina_components.\n \n \n\n \n \n Announcement Post\n \n \n Read about the skill's development.\n \n \n\n \n \n maquina_components Gem\n \n \n Install the component library." }, { "id": "documentation-ai-tools-mvp-creator", "title": "MVP Creator", "collection": { "label": "pages", "name": "Posts" }, "categories": "", "tags": "", "url": "/documentation/ai-tools/mvp-creator/", "content": "A Claude Code plugin that creates comprehensive MVP documentation for Rails applications through guided research and discovery. Go from idea to implementation-ready deliverables.\n\n\n\nWhat Is This?\n\nA Claude Code skill that:\n\n\n Researches your topic using web search and competitive analysis\n Guides you through discovery questions to refine the MVP scope\n Generates a Research Report with market context and competitor landscape\n Produces an MVP Business Plan with feature priorities and user stories\n Creates a Brand Guide with visual identity and tone of voice\n Builds a Technical Guide with architecture decisions and Rails conventions\n Configures Claude Setup (CLAUDE.md, .mcp.json, commands) for development handoff\n\n\n\n\nThe Workflow\n\n\n \n \n Step\n What Happens\n \n \n \n \n 1. Topic/Idea\n You describe your app concept or business idea\n \n \n 2. Research\n Skill researches competitors, market, and technology landscape\n \n \n 3. Discovery Questions\n Interactive Q&A to refine scope, audience, and priorities\n \n \n 4. Generate Deliverables\n Five documents produced in sequence\n \n \n 5. Handoff\n Ready for Spec-Driven Development to begin implementation\n \n \n\n\nThe skill walks you through each step interactively. You provide context and make decisions — the skill handles research, structure, and writing.\n\n\n\nQuick Start\n\n1. Add the Marketplace\n\n/plugin marketplace add maquina-app/rails-claude-code\n\n\n2. Install the Plugin\n\n/plugin install mvp-creator@maquina\n\n\n3. Start Creating\n\n> I have an idea for a project management app\n\n\n\n\nDeliverables\n\nEvery MVP session produces five documents:\n\n\n \n \n Deliverable\n Description\n \n \n \n \n Research Report\n Competitor analysis, market overview, feature comparison\n \n \n MVP Business Plan\n Vision, feature priorities, user flows, success metrics\n \n \n Brand Guide\n Logo direction, colors, typography, components, voice\n \n \n Technical Guide\n Architecture, patterns, data models, code style\n \n \n Claude Setup\n CLAUDE.md, .mcp.json, and commands for Claude Desktop/Code\n \n \n\n\nResearch Report\n\nThe skill searches the web for competitors, analyzes their features, pricing, and positioning. You get a structured comparison that informs every subsequent deliverable.\n\nMVP Business Plan\n\nDefines what to build first. Includes prioritized features, user stories, and success metrics. Scoped to what a single developer can ship.\n\nBrand Guide\n\nVisual identity decisions: color palette, typography, component styling, and tone of voice. Ready to apply when building the UI.\n\nTechnical Guide\n\nRails-specific architecture: models, associations, authentication approach, API patterns, and testing strategy. Follows 37signals conventions.\n\nClaude Setup\n\nPre-configured CLAUDE.md with project context, .mcp.json for MCP server integration, and custom commands. Drop these into your new Rails project and start building with full AI context.\n\n\n\nUsage Examples\n\nStart from an Idea\n\n> I have an idea for a project management app\n\n\nThe skill begins with research, then asks discovery questions to shape the MVP.\n\nPlan a SaaS Product\n\n> Help me plan a SaaS for freelancers\n\n\nThe skill treats this as a full MVP session — research, discovery, and all five deliverables.\n\nResearch Competitors\n\n> Research competitors for a booking system\n\n\nThe skill focuses on the research phase and produces a detailed competitor analysis.\n\nCreate a Business Plan\n\n> Create a business plan for my app idea\n\n\nSkips research if you already know the market. Goes straight to discovery and deliverables.\n\nDesign a Brand\n\n> Design a brand for my Rails project\n\n\nGenerates the Brand Guide deliverable with color palette, typography, and voice guidelines.\n\n\n\nPackage Contents\n\nmvp-creator/\n├── QUICKSTART.md # Quick reference\n└── skills/mvp-creator/\n ├── SKILL.md # Main skill\n ├── scripts/\n │ └── init.sh # Project initialization\n └── references/\n ├── rails-philosophy.md # Rails conventions and principles\n ├── rails-ui-patterns.md # UI design patterns\n ├── rails-api-patterns.md # API design patterns\n ├── rails-implementation-patterns.md # Implementation guidelines\n └── deliverable-templates/ # Templates for all 5 deliverables\n ├── research-report.md\n ├── mvp-business-plan.md\n ├── brand-guide.md\n ├── technical-guide.md\n └── claude-setup.md\n\n\n\n\nTeam Installation\n\nAdd to your project’s .claude/settings.json:\n\n{\n \"extraKnownMarketplaces\": {\n \"maquina\": {\n \"source\": {\n \"source\": \"github\",\n \"repo\": \"maquina-app/rails-claude-code\"\n }\n }\n },\n \"enabledPlugins\": [\n \"mvp-creator@maquina\"\n ]\n}\n\n\n\n\nNext Steps\n\n\n \n \n GitHub Repository\n \n \n View source code and contribute.\n \n \n\n \n \n Spec-Driven Development\n \n \n Continue from MVP to implementation with SDD.\n \n \n\n \n \n Announcement Post\n \n \n Watch the full 40-minute walkthrough video." }, { "id": "documentation-ai-tools-nvim-mcp-server", "title": "Neovim MCP Server", "collection": { "label": "pages", "name": "Posts" }, "categories": "", "tags": "", "url": "/documentation/ai-tools/nvim-mcp-server/", "content": "A Ruby implementation of a Model Context Protocol (MCP) server for Neovim integration. Coordinate file changes between your editor and AI assistants by reading and updating Neovim buffers directly.\n\n\n\nWhat is MCP?\n\nThe Model Context Protocol (MCP) is a standardized way for AI models to interact with their environment. It defines how models request and use tools, access resources, and maintain context.\n\nThe Neovim MCP Server exposes your editor’s buffers to AI assistants, enabling them to read file contents and push changes directly into your editing session.\n\n\n\nFeatures\n\n\n Read buffer contents from Neovim\n Update buffer contents with new code\n Coordinate changes between AI assistants and your editor\n Works with Claude Desktop and other MCP clients\n STDIO and HTTP server modes\n\n\n\n\nQuick Start\n\n1. Install the Gem\n\ngem install nvim-mcp-server\n\n\n2. Configure Neovim\n\nAdd to your init.lua to start the RPC server:\n\n-- Start the Neovim RPC server on a socket\nvim.fn.serverstart('/tmp/nvim-mcp.sock')\n\n\nOr start Neovim with a socket:\n\nnvim --listen /tmp/nvim-mcp.sock\n\n\n3. Configure Claude Desktop\n\nAdd to claude_desktop_config.json:\n\n{\n \"mcpServers\": {\n \"nvimMcpServer\": {\n \"command\": \"nvim-mcp-server\",\n \"args\": [\"--socket\", \"/tmp/nvim-mcp.sock\"]\n }\n }\n}\n\n\n\n\nAvailable Tools\n\nThe server provides 2 tools for buffer management.\n\n\n \n \n Tool\n Description\n \n \n \n \n get_project_buffers\n Get contents of all open buffers\n \n \n update_buffer\n Update a buffer with new content\n \n \n\n\nGet Project Buffers\n\nReturns the contents of all buffers currently open in Neovim:\n\nget_project_buffers()\n\n\nResponse includes file paths and their contents, allowing AI assistants to understand your current working context.\n\nUpdate Buffer\n\nUpdates a specific buffer with new content:\n\nupdate_buffer(file_path: \"/path/to/file.rb\", content: \"new content here\")\n\n\nThe changes appear immediately in Neovim, ready for you to review, modify, or save.\n\n\n\nServer Modes\n\nSTDIO Mode (Default)\n\nFor direct integration with Claude Desktop:\n\nnvim-mcp-server --socket /tmp/nvim-mcp.sock\n\n\nHTTP Mode\n\nFor HTTP endpoints with JSON-RPC and SSE:\n\nnvim-mcp-server --mode http --socket /tmp/nvim-mcp.sock\nnvim-mcp-server --mode http --socket /tmp/nvim-mcp.sock -p 8080\n\n\nEndpoints:\n\n JSON-RPC: http://localhost:6030/mcp/messages\n SSE: http://localhost:6030/mcp/sse\n\n\n\n\nNeovim Configuration\n\nSocket Setup\n\nThe MCP server communicates with Neovim via RPC over a Unix socket. Configure Neovim to listen:\n\nOption 1: In init.lua (recommended)\n\n-- Always start the socket server\nvim.fn.serverstart('/tmp/nvim-mcp.sock')\n\n\nOption 2: Shell alias\n\nalias nvim='nvim --listen /tmp/nvim-mcp.sock'\n\n\nOption 3: Per-session\n\nnvim --listen /tmp/nvim-mcp.sock\n\n\nMultiple Neovim Instances\n\nFor multiple Neovim instances, use unique socket paths:\n\n-- In init.lua\nlocal socket_path = '/tmp/nvim-mcp-' .. vim.fn.getpid() .. '.sock'\nvim.fn.serverstart(socket_path)\nprint('Neovim socket: ' .. socket_path)\n\n\nThen specify the socket when starting the MCP server:\n\nnvim-mcp-server --socket /tmp/nvim-mcp-12345.sock\n\n\n\n\nRuby Version Manager Users\n\nClaude Desktop bypasses version manager initialization. Use the Ruby shim path:\n\n{\n \"mcpServers\": {\n \"nvimMcpServer\": {\n \"command\": \"/home/your_user/.rbenv/shims/ruby\",\n \"args\": [\n \"/path/to/nvim-mcp-server/exe/nvim-mcp-server\",\n \"--socket\",\n \"/tmp/nvim-mcp.sock\"\n ]\n }\n }\n}\n\n\n\n\nTesting and Debugging\n\nUse MCP Inspector to test the server:\n\nnpm -g install @modelcontextprotocol/inspector\nnpx @modelcontextprotocol/inspector nvim-mcp-server --socket /tmp/nvim-mcp.sock\n\n\nThe Inspector UI lets you:\n\n See available tools\n Execute tool calls interactively\n View request and response details\n Debug issues in real-time\n\n\nVerify Neovim Socket\n\nCheck that Neovim is listening:\n\n# Should show the socket file\nls -la /tmp/nvim-mcp.sock\n\n\nFrom within Neovim, verify the server address:\n\n:echo v:servername\n\n\n\n\nUse Cases\n\nCode Review Workflow\n\n\n Open files in Neovim\n Ask Claude to review the open buffers\n Claude reads via get_project_buffers\n Claude suggests changes via update_buffer\n Review changes in Neovim before saving\n\n\nAI-Assisted Editing\n\nCombine with other MCP servers for powerful workflows:\n\n\n Use Rails MCP Server to understand your codebase\n Use Neovim MCP Server to apply changes directly to your editor\n Review and refine changes before committing\n\n\n\n\nNext Steps\n\n\n \n \n GitHub Repository\n \n \n Source code, issues, and contribution guidelines.\n \n \n\n \n \n Rails MCP Server\n \n \n Analyze models, routes, and schemas in your Rails projects." }, { "id": "documentation-ai-tools-rails-hotwire-driver", "title": "Rails Hotwire Driver", "collection": { "label": "pages", "name": "Posts" }, "categories": "", "tags": "", "url": "/documentation/ai-tools/rails-hotwire-driver/", "content": "A Claude Code skill that drives a running local Rails dev server from the shell — no browser required. Log in (including OTP/magic-link codes read straight from the log), submit ERB forms with the correct CSRF token, inspect Turbo Stream responses, and trace any request through the development log by its request id.\n\nIt is the runtime complement to the Rails MCP Server, which only reads code statically. This skill adds live interaction with a real, running app.\n\n\n\nWhat Is This?\n\nA Claude Code skill that lets Claude:\n\n\n Authenticate — submit login forms with the right CSRF token, and read OTP/verification codes that Rails prints to the dev log in development\n Submit forms — GET the page, read hidden inputs (including authenticity_token), merge your fields, and POST/PUT/PATCH/DELETE through ERB forms\n Inspect Turbo Streams — fire a request and read back the parsed action #target pairs the server returned\n Read the log safely — tail, grep, pull OTP patterns, or slice the exact lines for one X-Request-Id\n Bridge to Playwright — convert the curl session to/from Playwright storageState so you log in once and share the authenticated session between curl and a real browser\n\n\nIt is delivered as a skill (knowledge module plus shell scripts), not an autonomous agent. Claude reads SKILL.md and runs the scripts in scripts/ against your local app.\n\n\n\nWhen It Fits (and When It Doesn’t)\n\nGood fit: ERB + Hotwire apps with minimal JavaScript. The server renders HTML and text/vnd.turbo-stream.html; you are verifying that server-rendered contract.\n\nIt does not execute JavaScript. No Stimulus controllers run, no DOM morphing, no requestSubmit, no ActionCable-broadcast rendering. You can see a broadcast happen in the log (via request-id correlation), but not its DOM effect. For those cases, pair it with a browser-driving tool like the Playwright MCP — the session bridge means you only log in once.\n\n\n\nQuick Start\n\n1. Add the Marketplace\n\n/plugin marketplace add maquina-app/rails-claude-code\n\n\n2. Install the Plugin\n\n/plugin install rails-hotwire-driver@maquina\n\n\n3. Drive Your App\n\nWith your Rails app running locally (e.g. bin/rails s), just ask:\n\n> Log in as me@example.com and open the dashboard\n> Submit the new post form and show me which turbo-streams came back\n> Read the OTP code from the log and finish the login\n> Trace request abc-123 through the development log\n\n\n\n\nPrerequisites\n\nConfirm these before driving:\n\n\n The app is running locally in development, and you know its port. Set BASE_URL (default http://localhost:3000). The scripts refuse any non-local host — allowed: localhost, loopback IPs, and any *.localhost name.\n Nokogiri is available — it ships with essentially every Rails bundle. Run the Ruby scripts via the project bundle (bundle exec ruby ...).\n Recommended: request-id tagging for best log correlation. In config/environments/development.rb:\n config.log_tags = [ :request_id ]\n \n Without it, readlog.sh request falls back to a context window instead of an exact filter — still useful, just noisier.\n \n\n\nThese scripts only ever talk to a local server and only read the development log. Reading secrets like OTP codes out of a log is a development-only affordance — readlog.sh refuses any path containing production.\n\n\n\nThe Scripts\n\nAll live in scripts/. A shared cookie jar at ./.hotwire/cookies.txt carries the session across calls.\n\n\n \n \n Script\n Purpose\n \n \n \n \n req.sh\n One HTTP request with cookies persisted. Prints response headers (with X-Request-Id, Set-Cookie redacted) and the body.\n \n \n submit_form.rb\n Submit a form with the correct CSRF token. GETs the page, reads hidden inputs including authenticity_token, merges your fields, honors Rails’ _method field.\n \n \n readlog.sh\n Read the dev log safely — tail, grep, request <id>, or otp.\n \n \n flow.sh\n Full login → OTP → action in one command, all sharing the cookie jar.\n \n \n jar_to_storage.rb / storage_to_jar.rb\n Bridge the curl session to/from Playwright storageState.\n \n \n\n\nreq.sh — one request, cookies persisted\n\nreq.sh GET /products\nreq.sh GET /cart turbo # Accept: text/vnd.turbo-stream.html\nreq.sh GET /messages frame:inbox # Turbo-Frame: inbox (load a lazy frame)\nreq.sh POST /cart/add 'product_id=1&qty=2'\n\n\nsubmit_form.rb — the right CSRF token, every time\n\nThis is the tool for any POST/PUT/PATCH/DELETE through an ERB form. It eliminates the single most common hand-driving failure — a missing or stale CSRF token.\n\nbundle exec ruby scripts/submit_form.rb /session/new \"email=me@x.com\" \"password=secret\"\nbundle exec ruby scripts/submit_form.rb /posts/new \"form#new_post\" \"post[title]=Hi\"\n\n\nIt reports status, X-Request-Id, any redirect Location, and — for turbo-stream responses — a parsed list of action #target pairs.\n\nreadlog.sh — read the dev log safely\n\nreadlog.sh tail 200\nreadlog.sh grep 'SQL|SELECT' 500\nreadlog.sh request <x-request-id> # exact lines for one request (needs log_tags)\nreadlog.sh otp # grep common OTP / magic-link / token patterns\n\n\nflow.sh — login → OTP → action in one command\n\nOrchestrates the other three: submits the login form (CSRF handled), reads the OTP from the log scoped to the login’s request id (not a blind grep), submits the OTP, then optionally performs one authenticated action.\n\n# OTP / magic-link login, then hit an authenticated page:\nflow.sh --email me@x.com --password secret \\\n --login-path /session/new \\\n --otp-path /session/otp --otp-field code \\\n --then-path /dashboard --then-method GET\n\n# Password-only (omit --otp-path to skip the OTP steps):\nflow.sh --email me@x.com --password secret --then-path /account\n\n# Authenticated POST through a form (CSRF auto-handled):\nflow.sh --email me@x.com --otp-path /session/otp \\\n --then-path /posts/new --then-method POST --then-fields 'post[title]=Hi'\n\n\n\n\nCore Workflows\n\nOTP / magic-link login (the log trick)\n\nIn development, the mailer/notifier writes the code to the log rather than sending real email. flow.sh does this in one command; manually the steps are:\n\n\n Trigger it: submit_form.rb /session/new \"email=...\".\n Read the code: take the X-Request-Id from step 1, run readlog.sh request <id>, and extract the code.\n Submit it: submit_form.rb /otp \"code=123456\".\n\n\nVerify a Turbo Stream\n\n\n req.sh POST /cart/add 'product_id=1' turbo (or submit_form.rb for CSRF forms).\n Read the parsed action #target list to confirm the server returned the streams you expected (e.g. replace #cart_summary, append #flash).\n Correlate render details with readlog.sh request <X-Request-Id> — which partials rendered, what SQL ran.\n\n\nTrace one request end to end\n\nAny req.sh/submit_form.rb call prints X-Request-Id. Feed it to readlog.sh request <id> for a clean, single-request slice of the log — the most reliable way to see params, SQL, partial renders, and errors without log noise.\n\n\n\nPairing with Playwright\n\nThis skill verifies the server’s contract (turbo-stream actions, SQL, logs, the raw HTML before JS runs). Playwright verifies client behavior (did Stimulus wire up, did the stream actually mutate the DOM, did a lazy frame load). They’re complementary — the session bridge means you log in only once.\n\ncurl → Playwright (the common case)\n\nAuthenticate fast with the OTP-from-log trick, then hand the logged-in session to a real browser.\n\nflow.sh --email me@x.com --otp-path /session/otp --then-path /\nruby jar_to_storage.rb --origin http://fragua.localhost > state.json\n# then: npx @playwright/mcp@latest --storage-state state.json\n\n\nPlaywright → curl (reverse)\n\nIf a login is too JS-heavy for curl to replay (OAuth popup, Stimulus-driven form), let Playwright do it through the real UI, export its session, and drop back to the fast curl + log tools.\n\n# in Playwright: await context.storageState({ path: 'state.json' })\nruby storage_to_jar.rb --in state.json # writes ./.hotwire/cookies.txt\nreq.sh GET /dashboard # now authenticated\n\n\nThe bridge scripts emit the standard storageState format, so they work with the Playwright MCP, the Node test runner, or playwright-ruby-client.\n\n\n\nkamal-proxy and *.localhost Hosts\n\nIf you front your apps with kamal-proxy and reach them at names like http://fragua.localhost, set BASE_URL=http://fragua.localhost (with the port if not 80). The proxy routes by the Host header, which curl and Net::HTTP send automatically.\n\n*.localhost resolves to loopback on macOS and most browsers, but not always on Linux. Force resolution with RESOLVE:\n\nRESOLVE=1 BASE_URL=http://fragua.localhost:80 req.sh GET /\n# connects to 127.0.0.1 but still sends Host: fragua.localhost\n\n\nRESOLVE works for both req.sh and submit_form.rb; the Host header is preserved for routing either way. Point LOG_FILE at the specific app’s log/development.log, since each app under the proxy has its own log.\n\n\n\nConfiguration\n\nSet via environment variables:\n\n\n \n \n Variable\n Default\n Purpose\n \n \n \n \n BASE_URL\n http://localhost:3000\n Target server. For kamal-proxy use the routed name.\n \n \n RESOLVE\n (off)\n Force the host to resolve to an IP. RESOLVE=1 → 127.0.0.1; RESOLVE=<ip> → that IP.\n \n \n JAR\n ./.hotwire/cookies.txt\n Cookie jar path.\n \n \n LOG_FILE\n ./log/development.log\n Log to read (point at the specific app’s log).\n \n \n MAX_BYTES\n 100000\n Response body cap for req.sh.\n \n \n\n\n\n\nGuardrails\n\nThese are deliberate — don’t weaken them:\n\n\n Local only. Both shell scripts reject non-localhost hosts.\n No production logs. readlog.sh refuses paths containing production.\n Don’t echo cookies. req.sh redacts Set-Cookie; report auth state, not the cookie value.\n Keep these separate from rails-mcp-server — that server is introspection-only (it does not execute arbitrary Ruby or make network requests). These scripts are a deliberately separate, narrowly-scoped affordance.\n\n\n\n\nPackage Contents\n\nrails-hotwire-driver/\n└── skills/\n └── rails-hotwire-driver/\n ├── SKILL.md # Skill knowledge module\n └── scripts/\n ├── req.sh # One HTTP request, cookies persisted\n ├── submit_form.rb # CSRF-correct form submit\n ├── readlog.sh # Safe dev-log reader\n ├── flow.sh # login → OTP → action\n ├── jar_to_storage.rb # curl jar → Playwright storageState\n └── storage_to_jar.rb # Playwright storageState → curl jar\n\n\n\n\nTeam Installation\n\nAdd to your project’s .claude/settings.json:\n\n{\n \"extraKnownMarketplaces\": {\n \"maquina\": {\n \"source\": {\n \"source\": \"github\",\n \"repo\": \"maquina-app/rails-claude-code\"\n }\n }\n },\n \"enabledPlugins\": [\n \"rails-hotwire-driver@maquina\"\n ]\n}\n\n\n\n\nNext Steps\n\n\n \n \n GitHub Repository\n \n \n View source code and contribute.\n \n \n\n \n \n Rails MCP Server\n \n \n Pair static code analysis with live runtime interaction.\n \n \n\n \n \n Better Stimulus\n \n \n Write the Stimulus controllers behind your Hotwire UI." }, { "id": "documentation-ai-tools-rails-mcp-server", "title": "Rails MCP Server", "collection": { "label": "pages", "name": "Posts" }, "categories": "", "tags": "", "url": "/documentation/ai-tools/rails-mcp-server/", "content": "A Ruby implementation of a Model Context Protocol (MCP) server for Rails projects. Let LLMs interact with your Rails codebase through code analysis, exploration, and development assistance.\n\nCurrent Version: 2.0.0\n\n\n\nWhat is MCP?\n\nThe Model Context Protocol (MCP) is a standardized way for AI models to interact with their environment. It defines a structured method for models to request and use tools, access resources, and maintain context during interactions.\n\nRails MCP Server implements the MCP specification to give AI models access to Rails projects for code analysis, exploration, and assistance.\n\n\n\nFeatures\n\n\n Manage multiple Rails projects with auto-detection\n Browse project files and structures\n View Rails routes with filtering\n Inspect model information and relationships (Prism static analysis)\n Get database schema information\n Analyze controller-view relationships\n Analyze environment configurations\n Read and glob project files through dedicated tools\n Access Rails, Turbo, Stimulus, and Kamal documentation\n Context-efficient architecture with progressive tool discovery\n GitHub Copilot Agent support (v1.5.0+)\n Rails 8.1+ compatibility (v1.5.0+)\n\n\n\n\nQuick Start\n\n1. Install the Gem\n\ngem install rails-mcp-server\n\n\n2. Configure Projects\n\nOption A: Interactive configuration\n\nrails-mcp-config\n\n\nThis provides a TUI for managing projects, downloading guides, and configuring Claude Desktop.\n\nOption B: Single-project mode (v1.5.0+)\n\nFor quick usage with the current directory:\n\ncd /path/to/your/rails/app\nrails-mcp-server --single-project\n\n\nOption C: Environment variable (v1.5.0+)\n\nexport RAILS_MCP_PROJECT_PATH=/path/to/your/rails/app\nrails-mcp-server\n\n\n3. Configure Your AI Client\n\nClaude Desktop\n\nSelect “Claude Desktop integration” in the configuration tool, or manually add to claude_desktop_config.json:\n\n{\n \"mcpServers\": {\n \"railsMcpServer\": {\n \"command\": \"ruby\",\n \"args\": [\"/path/to/rails-mcp-server/exe/rails-mcp-server\"]\n }\n }\n}\n\n\nGitHub Copilot Agent (v1.5.0+)\n\nSee the Copilot Agent Setup Guide for detailed instructions.\n\n\n\nProject Detection (v1.5.0+)\n\nThe server uses priority-based project detection:\n\n\n \n \n Priority\n Method\n Description\n \n \n \n \n 1 (Highest)\n RAILS_MCP_PROJECT_PATH env var\n Explicit path to project\n \n \n 2\n --single-project flag\n Uses current working directory\n \n \n 3\n Auto-detection\n Detects Rails apps (Gemfile) or engines (gemspec)\n \n \n 4 (Lowest)\n projects.yml\n Traditional multi-project configuration\n \n \n\n\nWhen only one project is configured, the server auto-switches to it.\n\n\n\nAvailable Tools\n\nThe server provides 3 registered tools plus internal analyzers accessible via execute_tool.\n\n\n Removed in v2.0.0: the execute_ruby tool has been removed. The server is now introspection-only — use the dedicated analyzers below (e.g. get_file, list_files, get_routes, get_schema). See Migrating from execute_ruby.\n\n\nRegistered Tools\n\n\n \n \n Tool\n Description\n \n \n \n \n switch_project\n Change the active Rails project\n \n \n search_tools\n Discover available tools by category or keyword\n \n \n execute_tool\n Invoke internal analyzers by name\n \n \n\n\nInternal Analyzers\n\n\n \n \n Analyzer\n Description\n \n \n \n \n project_info\n Project information, Rails version, directory structure\n \n \n list_files\n List files matching a pattern\n \n \n get_file\n Retrieve file content\n \n \n get_routes\n Rails routes with filtering\n \n \n analyze_models\n Active Record models with associations and validations\n \n \n get_schema\n Database schema information\n \n \n analyze_controller_views\n Controller-view relationships\n \n \n analyze_environment_config\n Environment configuration analysis\n \n \n load_guide\n Load documentation guides\n \n \n\n\n\n\nUsage Examples\n\nSwitch Project\n\nswitch_project(project_name: \"my_rails_app\")\n\n\nGet Routes\n\nexecute_tool(tool_name: \"get_routes\")\nexecute_tool(tool_name: \"get_routes\", params: { controller: \"users\" })\nexecute_tool(tool_name: \"get_routes\", params: { verb: \"POST\" })\n\n\nAnalyze Models\n\nexecute_tool(tool_name: \"analyze_models\")\nexecute_tool(tool_name: \"analyze_models\", params: { model_name: \"User\" })\nexecute_tool(tool_name: \"analyze_models\", params: { model_name: \"User\", analysis_type: \"full\" })\n\n\nTips:\n\n Use CamelCase singular: User, BlogPost, OrderItem\n Use analysis_type: \"full\" to include Prism static analysis (callbacks, scopes, methods)\n\n\nGet Schema\n\nexecute_tool(tool_name: \"get_schema\")\nexecute_tool(tool_name: \"get_schema\", params: { table_name: \"users\" })\nexecute_tool(tool_name: \"get_schema\", params: { detail_level: \"tables\" })\n\n\nTips:\n\n Use snake_case plural: users, blog_posts, order_items\n Use detail_level: \"tables\" for a quick table list\n\n\nRead a File\n\nexecute_tool(tool_name: \"get_file\", params: { path: \"Gemfile\" })\nexecute_tool(tool_name: \"get_file\", params: { path: \"app/models/user.rb\" })\n\n\nPaths are relative to the project root. Reads are confined to the project directory, and sensitive files (.env, credentials, keys) are refused.\n\nFind Files\n\nexecute_tool(tool_name: \"list_files\", params: { pattern: \"app/models/**/*.rb\" })\nexecute_tool(tool_name: \"list_files\", params: { pattern: \"app/**/*user*\" })\n\n\nMigrating from execute_ruby\n\nThe execute_ruby tool was removed in v2.0.0. It ran caller-supplied Ruby via bin/rails runner, which made it an arbitrary-code-execution surface a pattern-based sandbox could not safely contain. The server is an introspection tool, and its dedicated analyzers cover what execute_ruby was used for:\n\n\n \n \n Old execute_ruby usage\n Use instead\n \n \n \n \n read_file(path)\n execute_tool(tool_name: \"get_file\", params: { path: … })\n \n \n list_files(pattern)\n execute_tool(tool_name: \"list_files\", params: { pattern: … })\n \n \n file_exists? / project_root\n list_files / execute_tool(tool_name: \"project_info\")\n \n \n Routes / schema / models / controllers\n get_routes, get_schema, analyze_models, analyze_controller_views\n \n \n\n\nAd-hoc live data queries (User.count, custom scopes) are intentionally no longer supported. If you rely on free-form execution, pin to the 1.6.x line, which retains the hardened execute_ruby.\n\n\n\nServer Modes\n\nSTDIO Mode (Default)\n\nFor direct integration with Claude Desktop:\n\nrails-mcp-server\n\n\nSingle-Project Mode (v1.5.0+)\n\nFor working with the current directory only:\n\ncd /path/to/rails/app\nrails-mcp-server --single-project\n\n\nHTTP Mode\n\nFor HTTP endpoints with JSON-RPC and SSE:\n\nrails-mcp-server --mode http\nrails-mcp-server --mode http -p 8080\nrails-mcp-server --mode http --bind-all # Allow LAN access\n\n\nEndpoints:\n\n JSON-RPC: http://localhost:6029/mcp/messages\n SSE: http://localhost:6029/mcp/sse\n\n\n\n\nConfiguration\n\nEnvironment Variable (v1.5.0+)\n\nSet the project path explicitly:\n\nexport RAILS_MCP_PROJECT_PATH=~/projects/my-rails-app\nrails-mcp-server\n\n\nManual Project Configuration\n\nEdit ~/.config/rails-mcp/projects.yml:\n\nstore: \"~/projects/store\"\nblog: \"~/projects/rails-blog\"\necommerce: \"/full/path/to/ecommerce-app\"\n\n\nRuby Version Manager Users\n\nClaude Desktop bypasses version manager initialization. Use the Ruby shim path:\n\n{\n \"mcpServers\": {\n \"railsMcpServer\": {\n \"command\": \"/home/your_user/.rbenv/shims/ruby\",\n \"args\": [\"/path/to/rails-mcp-server/exe/rails-mcp-server\"]\n }\n }\n}\n\n\nThe rails-mcp-config tool detects this automatically.\n\n\n\nDocumentation Resources\n\nAccess comprehensive documentation through load_guide:\n\nexecute_tool(tool_name: \"load_guide\", params: { library: \"rails\" })\nexecute_tool(tool_name: \"load_guide\", params: { library: \"rails\", guide: \"getting_started\" })\nexecute_tool(tool_name: \"load_guide\", params: { library: \"rails\", guide: \"active_record_basics\" })\nexecute_tool(tool_name: \"load_guide\", params: { library: \"turbo\" })\nexecute_tool(tool_name: \"load_guide\", params: { library: \"stimulus\" })\nexecute_tool(tool_name: \"load_guide\", params: { library: \"kamal\" })\nexecute_tool(tool_name: \"load_guide\", params: { library: \"custom\" })\n\n\nAvailable libraries:\n\n\n \n \n Library\n Content\n \n \n \n \n rails\n Official Rails Guides\n \n \n turbo\n Hotwire Turbo handbook and reference\n \n \n stimulus\n Stimulus handbook and reference\n \n \n kamal\n Kamal deployment documentation\n \n \n custom\n User-added custom guides\n \n \n\n\nDownload guides using the configuration tool:\n\nrails-mcp-config\n# Select \"Download guides\"\n\n\n\n Breaking Change in v1.5.0: The guides parameter was renamed to library.\n \n Old: params: { guides: \"rails\" }\n New: params: { library: \"rails\" }\n \n\n\n\n\nAnalyzer Parameter Reference\n\n\n \n \n Analyzer\n Required\n Optional Parameters\n \n \n \n \n project_info\n -\n max_depth, include_files, detail_level\n \n \n list_files\n -\n directory, pattern\n \n \n get_file\n path\n -\n \n \n get_routes\n -\n controller, verb, path_contains, named_only, detail_level\n \n \n analyze_models\n -\n model_name, model_names, detail_level, analysis_type\n \n \n get_schema\n -\n table_name, table_names, detail_level\n \n \n analyze_controller_views\n -\n controller_name, detail_level, analysis_type\n \n \n analyze_environment_config\n -\n (none)\n \n \n load_guide\n library\n guide\n \n \n\n\nCommon Parameter Values\n\ndetail_level:\n\n names - Minimal output (just names/paths)\n summary - Compact overview\n full - Complete details (default)\n\n\nanalysis_type (for models and controllers):\n\n introspection - Uses Rails runtime APIs (default)\n static - Uses Prism AST parsing\n full - Both introspection and static analysis\n\n\n\n\nUsing with MCP Proxy\n\nFor STDIO-only clients that need HTTP/SSE capabilities:\n\n# Start server in HTTP mode\nrails-mcp-server --mode http\n\n# Install and run MCP proxy\nnpm install -g mcp-remote\nnpx mcp-remote http://localhost:6029/mcp/sse\n\n\nConfigure Claude Desktop to use the proxy:\n\n{\n \"mcpServers\": {\n \"railsMcpServer\": {\n \"command\": \"npx\",\n \"args\": [\"mcp-remote\", \"http://localhost:6029/mcp/sse\"]\n }\n }\n}\n\n\n\n\nTesting and Debugging\n\nUse MCP Inspector to test the server:\n\nnpm -g install @modelcontextprotocol/inspector\nnpx @modelcontextprotocol/inspector /path/to/rails-mcp-server\n\n\nThe Inspector UI lets you:\n\n See all available tools\n Execute tool calls interactively\n View request and response details\n Debug issues in real-time\n\n\n\n\nSecurity\n\nIntrospection-only (v2.0.0+)\n\nThe server does not execute caller-supplied Ruby. It exposes a fixed set of introspection analyzers, so there is no arbitrary-code-execution surface. (The free-form execute_ruby tool was removed in v2.0.0.)\n\nThe tools that boot the app (get_schema, get_routes, and the introspection half of analyze_models / analyze_controller_views) run bin/rails runner with fixed, server-authored scripts; caller input is passed as validated parameters, never interpolated as code. Booting a project runs that project’s environment, so point the server only at Rails projects you trust.\n\nInput Validation\n\nAll file-accessing tools use centralized input validation (PathValidator):\n\n\n Path traversal prevention - Blocks ../ and absolute paths that escape the project root\n Sensitive file protection - Filters master.key, credentials.yml.enc, .env files\n Shell injection prevention - Uses safe argument passing\n SQL injection prevention - Validates table names in schema queries\n\n\n\n\nCompatibility\n\n\n \n \n Component\n Supported Versions\n \n \n \n \n Ruby\n 3.3+ (Ruby 3.2 dropped in v1.6.0)\n \n \n Rails (target projects)\n 6.0+\n \n \n Rails 8.1.1+\n Full support (v1.5.0+)\n \n \n Claude Desktop\n Supported\n \n \n GitHub Copilot Agent\n Supported (v1.5.0+)\n \n \n Other MCP Clients\n Via STDIO or HTTP mode\n \n \n\n\n\n\nChangelog Highlights\n\nv2.0.0\n\nBreaking Changes:\n\n Removed the execute_ruby tool. The server is now introspection-only; use the dedicated analyzers (see Migrating from execute_ruby). Bootstrap tools reduced from 4 to 3.\n\n\nSecurity:\n\n Removing execute_ruby eliminates the arbitrary-code-execution surface behind the v1.6.x hardening series.\n\n\nv1.6.x\n\nSecurity:\n\n Hardened, then removed, the execute_ruby sandbox: blocked the PTY.spawn command-execution path and other stdlib escapes, hard-blocked dynamic dispatch to execution sinks, and fixed a ReDoS in the static scan (v1.6.1). Thanks to Pluto Security for the responsible disclosure.\n Puma upgraded to 8.0.2, clearing CVE-2026-47736 / CVE-2026-47737.\n\n\nBreaking Changes:\n\n Dropped Ruby 3.2 support (minimum is now Ruby 3.3).\n\n\nv1.5.0\n\nNew Features:\n\n GitHub Copilot Agent support\n --single-project flag for single-project mode\n RAILS_MCP_PROJECT_PATH environment variable\n Auto-detection of Rails apps and engines\n Auto-switch when only one project configured\n\n\nSecurity:\n\n Added PathValidator for centralized input sanitization\n Added CI security infrastructure (Dependabot, CodeQL, OpenSSF Scorecard)\n\n\nBreaking Changes:\n\n load_guide parameter renamed: guides → library\n\n\n\n\nNext Steps\n\n\n \n \n GitHub Repository\n \n \n Source code, issues, and contribution guidelines.\n \n \n\n \n \n AI Agent Guide\n \n \n Comprehensive guide for AI agents using this server.\n \n \n\n \n \n GitHub Copilot Setup\n \n \n Configure Rails MCP Server with GitHub Copilot Agent.\n \n \n\n \n \n RubyGems\n \n \n Install the latest version from RubyGems." }, { "id": "documentation-ai-tools-rails-security-auditor", "title": "Rails Security Auditor", "collection": { "label": "pages", "name": "Posts" }, "categories": "", "tags": "", "url": "/documentation/ai-tools/rails-security-auditor/", "content": "A Claude Code plugin that audits a Rails application’s security configuration and produces a severity-grouped, actionable report grounded in Rails 8.0–8.2 defaults. Optionally applies fixes for any finding.\n\n\n\nWhat Is This?\n\nA Claude Code agent that:\n\n\n Scans your Rails config: production environment, initializers, controllers, Gemfile, and CI workflows\n Detects your Rails version from Gemfile.lock and adjusts expectations accordingly\n Runs 10 categories of security checks against current Rails defaults\n Groups findings by severity (Critical, High, Medium, Informational)\n Explains each finding in plain language — what it is, why it matters, how to fix it\n Applies fixes on request — single finding, all Critical, or all findings\n\n\nThe agent runs autonomously: it reads the files it needs, produces the full report, and pauses to offer fixes.\n\n\n\nQuick Start\n\n1. Add the Marketplace\n\n/plugin marketplace add maquina-app/rails-claude-code\n\n\n2. Install the Plugin\n\n/plugin install rails-security-auditor@maquina\n\n\n3. Run the Audit\n\n> /audit-security\n\n\nOr simply ask:\n\n> Audit my Rails app's security configuration\n> Check if my CSRF setup is correct\n> Am I missing any security headers?\n\n\n\n\nWhat It Checks\n\nTen check categories, drawn from the full catalog in references/checks.md:\n\n\n \n \n Category\n Scope\n \n \n \n \n PROD\n force_ssl, assume_ssl, log level, filter_parameters, trusted proxies\n \n \n CSRF\n protect_from_forgery, strategy, per-form tokens\n \n \n HDR\n X-Frame-Options, X-Content-Type-Options, X-XSS-Protection, Referrer-Policy\n \n \n CSP\n Content Security Policy initializer and directives\n \n \n SESS\n Session cookie SameSite, expire_after, httponly\n \n \n RATE\n rate_limit macro, Rack::Attack throttles and safelists\n \n \n AUTH\n Authorization gem, tenant scoping, allow_unauthenticated_access\n \n \n GEM\n Brakeman, bundler-audit, Rails CVEs\n \n \n CI\n Security scanning in CI pipeline\n \n \n FWKD\n Rails 8.2 framework defaults (CSRF header strategy, transaction-aware jobs)\n \n \n\n\nPlus: column-level encryption (encrypts) and hardcoded secrets scans.\n\n\n\nHow Findings Are Reported\n\nEach finding follows a consistent structure:\n\n### [PROD-01] force_ssl missing in production\nFile: config/environments/production.rb\nFound: config.force_ssl is not set\n\nWhy this matters:\nWithout force_ssl, users on HTTP don't get redirected to HTTPS.\nCookies and sessions can travel in the clear on public networks.\n\nHow to fix it:\nconfig.force_ssl = true\n\nOffer: Would you like me to apply this fix?\n\n\nSeverity counts go at the top of the report:\n\n\n \n \n Severity\n Meaning\n \n \n \n \n ❌ Critical\n Active vulnerability — fix immediately\n \n \n ⚠️ High\n Important gap — fix soon\n \n \n 🔶 Medium\n Hardening — recommended\n \n \n ℹ️ Informational\n Optional improvement\n \n \n ✅ Passed\n Already configured correctly\n \n \n\n\n\n\nAudit Principles\n\nThe auditor is designed to minimize noise:\n\n\n Intentional configuration isn’t flagged. If assume_ssl is set alongside force_ssl, the agent recognizes it as a Cloudflare/Kamal setup and doesn’t mark force_ssl redirect as missing.\n Version-aware. A Rails 7.1 app isn’t expected to have 8.2 defaults; checks adapt to the detected version.\n Missing files are findings. No rack_attack.rb → finding. No content_security_policy.rb → finding.\n Ambiguous intent surfaces as “Verify:” rather than an assertion of wrongness.\n Severity is guidance, not a verdict. The agent adjusts when the app’s context makes a finding materially more or less risky.\n\n\n\n\nApplying Fixes\n\nAfter the report, you can fix findings one at a time, all Critical findings at once, or everything:\n\n> Fix all Critical findings\n> Apply the CSP fix only\n> Create the missing rack_attack.rb initializer\n\n\nFor each fix the agent reads the current file, applies the minimal change needed, shows a before/after diff, and confirms the write. When a fix requires creating a new initializer from scratch, it generates the full file.\n\n\n\nPackage Contents\n\nrails-security-auditor/\n├── agents/rails-security-auditor.md # Main agent\n└── references/\n └── checks.md # Full check catalog with fixes\n\n\n\n\nTeam Installation\n\nAdd to your project’s .claude/settings.json:\n\n{\n \"extraKnownMarketplaces\": {\n \"maquina\": {\n \"source\": {\n \"source\": \"github\",\n \"repo\": \"maquina-app/rails-claude-code\"\n }\n }\n },\n \"enabledPlugins\": [\n \"rails-security-auditor@maquina\"\n ]\n}\n\n\nWire /audit-security into your release checklist so every major deploy starts with a clean report.\n\n\n\nNext Steps\n\n\n \n \n GitHub Repository\n \n \n View source code and contribute.\n \n \n\n \n \n Rails Upgrade Assistant\n \n \n Pair with upgrades to pick up new security defaults.\n \n \n\n \n \n Rails Simplifier\n \n \n Clean up code patterns discovered during an audit." }, { "id": "documentation-ai-tools-rails-simplifier", "title": "Rails Simplifier", "collection": { "label": "pages", "name": "Posts" }, "categories": "", "tags": "", "url": "/documentation/ai-tools/rails-simplifier/", "content": "A Claude Code plugin that refines Ruby on Rails code following 37signals patterns and the One Person Framework philosophy. Transform complex code into clean, maintainable Rails conventions.\n\n\n\nWhat Is This?\n\nA Claude Code skill that:\n\n\n Simplifies service objects into rich model methods and concerns\n Converts custom controller actions to CRUD resources\n Transforms boolean state columns into state records\n Optimizes fat controllers into thin controllers with model methods\n Applies Rails best practices like I18n, Time.current, and eager loading\n Detects N+1 queries and suggests fixes\n\n\n\n\nPhilosophy\n\nThe One Person Framework\n\nFrom DHH (December 2021):\n\n\n “A toolkit so powerful that it allows a single individual to create modern applications upon which they might build a competitive business.”\n\n\nConceptual Compression\n\nFrom RailsConf 2018:\n\n\n “Like a video codec that throws away irrelevant details such that you might download the film in real-time.”\n\n\nVanilla Rails is Plenty\n\nFrom Jorge Manrubia at 37signals:\n\n\n “If you have the luxury of starting a new Rails app today, go vanilla.”\n\n\n\n\nQuick Start\n\n1. Add the Marketplace\n\n/plugin marketplace add maquina-app/rails-claude-code\n\n\n2. Install the Plugin\n\n/plugin install rails-simplifier@maquina\n\n\n3. Start Simplifying\n\n> Review recent changes using the rails-simplifier skill\n\n\n\n\nWhat It Simplifies\n\n\n \n \n Pattern\n Simplification\n \n \n \n \n Service objects\n Rich model methods + concerns\n \n \n Custom controller actions\n CRUD resources\n \n \n Boolean state columns\n State records (has_one :closure)\n \n \n Fat controllers\n Thin controllers, model methods\n \n \n Time.now\n Time.current\n \n \n Hardcoded strings\n I18n keys\n \n \n N+1 queries\n includes / preload\n \n \n Date tests without travel_to\n Freeze time to fixture\n \n \n\n\n\n\nUsage Examples\n\nReview Recent Changes\n\n> Review recent changes using the rails-simplifier skill\n\n\nThe skill analyzes your recent commits and suggests simplifications based on 37signals patterns.\n\nReview a Specific Controller\n\n> Use rails-simplifier to review the bookings controller\n\n\nReview a Model\n\n> Use rails-simplifier to review the Order model\n\n\nFull Project Review\n\n> Run rails-simplifier on the app directory\n\n\n\n\nSimplification Patterns\n\nService Objects to Model Methods\n\nBefore:\n\n# app/services/order_processor.rb\nclass OrderProcessor\n def initialize(order)\n @order = order\n end\n\n def process\n @order.update(processed_at: Time.current)\n @order.line_items.each(&:fulfill)\n OrderMailer.confirmation(@order).deliver_later\n end\nend\n\n# In controller\nOrderProcessor.new(@order).process\n\n\nAfter:\n\n# app/models/order.rb\nclass Order < ApplicationRecord\n def process!\n update(processed_at: Time.current)\n line_items.each(&:fulfill)\n OrderMailer.confirmation(self).deliver_later\n end\nend\n\n# In controller\n@order.process!\n\n\nBoolean States to State Records\n\nBefore:\n\nclass Post < ApplicationRecord\n scope :published, -> { where(published: true) }\n scope :draft, -> { where(published: false) }\nend\n\n\nAfter:\n\nclass Post < ApplicationRecord\n has_one :publication\n\n scope :published, -> { joins(:publication) }\n scope :draft, -> { where.missing(:publication) }\n\n def publish!\n create_publication!\n end\n\n def unpublish!\n publication&.destroy\n end\nend\n\n\nCustom Actions to CRUD\n\nBefore:\n\n# config/routes.rb\nresources :posts do\n member do\n post :publish\n post :unpublish\n post :archive\n end\nend\n\n# app/controllers/posts_controller.rb\ndef publish\n @post.update(published: true)\n redirect_to @post\nend\n\n\nAfter:\n\n# config/routes.rb\nresources :posts do\n resource :publication, only: [:create, :destroy]\n resource :archival, only: [:create, :destroy]\nend\n\n# app/controllers/publications_controller.rb\nclass PublicationsController < ApplicationController\n def create\n @post = Post.find(params[:post_id])\n @post.create_publication!\n redirect_to @post\n end\n\n def destroy\n @post = Post.find(params[:post_id])\n @post.publication.destroy\n redirect_to @post\n end\nend\n\n\nN+1 Query Detection\n\nBefore:\n\ndef index\n @posts = Post.all\nend\n\n# In view: @posts.each { |post| post.author.name }\n\n\nAfter:\n\ndef index\n @posts = Post.includes(:author)\nend\n\n\n\n\nTeam Installation\n\nAdd to your project’s .claude/settings.json:\n\n{\n \"extraKnownMarketplaces\": {\n \"maquina\": {\n \"source\": {\n \"source\": \"github\",\n \"repo\": \"maquina-app/rails-claude-code\"\n }\n }\n },\n \"enabledPlugins\": [\n \"rails-simplifier@maquina\"\n ]\n}\n\n\n\n\nResources\n\n\n 37signals Rails Patterns — Collection of patterns from 37signals\n Jorge Manrubia’s Blog — Rails architecture insights\n Rails Doctrine — The philosophy behind Rails\n\n\n\n\nNext Steps\n\n\n \n \n GitHub Repository\n \n \n View source code and contribute.\n \n \n\n \n \n Rails MCP Server\n \n \n Enhance analysis with MCP tools." }, { "id": "documentation-ai-tools-rails-upgrade-skill", "title": "Rails Upgrade Skill", "collection": { "label": "pages", "name": "Posts" }, "categories": "", "tags": "", "url": "/documentation/ai-tools/rails-upgrade-skill/", "content": "A comprehensive Claude skill that helps you upgrade Ruby on Rails applications through any version from 6.0 to 8.1.1. Built on official Rails CHANGELOGs. It analyzes your project with Claude Code’s built-in file tools — no external services required.\n\n\n\nWhat Is This?\n\nA Claude skill that:\n\n\n Analyzes your Rails project automatically by reading its files\n Detects your current version and target version\n Plans single-hop or multi-hop upgrade paths\n Identifies breaking changes specific to your code\n Preserves custom configurations with warnings\n Generates comprehensive upgrade reports\n Applies the fixes for you, editing the files directly\n Based on official Rails CHANGELOGs from GitHub\n\n\n\n\nQuick Start\n\n1. Install the Plugin\n\n/plugin marketplace add maquina-app/rails-claude-code\n/plugin install rails-upgrade-assistant@maquina\n\n\n2. Start Upgrading\n\nSay to Claude:\n\n\"Upgrade my Rails app to 8.1\"\n\n\nClaude will:\n\n\n Detect your current version from Gemfile.lock and plan the path (single or multi-hop)\n Generate a breaking-changes detection script and run it\n Evaluate the findings against your actual code\n Produce a comprehensive upgrade report with OLD → NEW examples\n Offer to apply the fixes directly\n\n\n\n\nSupported Upgrade Paths\n\n\n \n \n From\n To\n Hops\n Breaking Changes\n Difficulty\n \n \n \n \n 8.0.x\n 8.1.1\n 1\n 8 changes\n Easy\n \n \n 7.2.x\n 8.0.4\n 1\n 13 changes\n Hard\n \n \n 7.1.x\n 7.2.3\n 1\n 38 changes\n Medium\n \n \n 7.0.x\n 7.1.6\n 1\n 12 changes\n Medium\n \n \n 6.1.x\n 7.0.0\n 1\n 17 changes\n Hard\n \n \n 6.0.x\n 6.1.0\n 1\n 18 changes\n Medium\n \n \n 6.0.x\n 8.1.1\n 6\n 106 changes\n Very Hard\n \n \n\n\nSequential Upgrades Required\n\nRails upgrades must be sequential:\n\nCorrect: 6.0 → 6.1 → 7.0 → 7.1 → 7.2 → 8.0 → 8.1\nWrong: 6.0 → 7.0 (skips 6.1)\n\n\nFor multi-hop upgrades, Claude will:\n\n\n Explain the sequential requirement\n Plan all intermediate hops\n Generate separate reports for each hop\n Guide you through completing each hop before moving to next\n\n\n\n\nHow It Works\n\nFull upgrade (the default)\n\nBest for understanding what needs to change before making edits.\n\n\"Upgrade my Rails app from 7.2 to 8.0\"\n\n\nClaude will:\n\n\n Read Gemfile.lock to detect the current version\n Load appropriate version guide(s)\n Analyze your project files for custom code\n Identify breaking changes affecting your code\n Generate comprehensive upgrade report\n\n\nYou remain in control and apply changes manually.\n\nQuery-specific\n\nBest for specific questions about changes.\n\n\"What ActiveRecord changes are in Rails 8.0?\"\n\"How do I handle the SSL configuration change?\"\n\"What breaking changes affect my models?\"\n\"Will my Redis cache work after upgrading to 8.0?\"\n\n\n\n\nKey Breaking Changes by Version\n\nRails 8.0 → 8.1\n\nHigh impact:\n\n\n SSL configuration now commented out (affects non-Kamal deploys)\n Database pool: renamed to max_connections:\n bundler-audit script required\n\n\nRails 7.2 → 8.0\n\nHigh impact:\n\n\n Asset pipeline: Sprockets → Propshaft\n Solid gems: New defaults for cache/queue/cable\n Multi-database config required for Solid gems\n\n\nRails 7.1 → 7.2\n\nHigh impact:\n\n\n Transaction-aware job enqueuing (behavior change)\n ActiveRecord::Base.connection deprecated\n show_exceptions changed from boolean to symbol\n Rails.application.secrets removed\n\n\nRails 7.0 → 7.1\n\nHigh impact:\n\n\n cache_classes → enable_reloading (inverted logic)\n Force SSL now default in production\n SQLite database moved to storage/\n\n\nRails 6.1 → 7.0\n\nHigh impact:\n\n\n Zeitwerk autoloader required (Classic removed)\n rails command replaces rake for most tasks\n Spring removed from default Gemfile\n ActiveSupport::Dependencies autoloading deprecated\n\n\nRails 6.0 → 6.1\n\nHigh impact:\n\n\n Per-database connection handling changes\n ActiveRecord::Base#connection pool behavior updated\n Hotwire (Turbo + Stimulus) introduced as default frontend\n rails db:prepare added as preferred setup command\n\n\n\n\nCustom Code Detection\n\nThe skill automatically detects and warns about customizations:\n\nDatabase Configuration\n\n# Custom SQLite path detected in config/database.yml\n# Current: database: db/development.sqlite3\n# Rails 7.1+: database: storage/development.sqlite3\n# Action: Review and update path\n\n\nSSL Middleware\n\n# Custom SSL middleware detected in config/application.rb\n# Line 23: middleware.use CustomSSLMiddleware\n# Rails 7.1+: May conflict with config.force_ssl = true\n# Action: Review compatibility\n\n\nAutoload Paths\n\n# Custom autoload_paths in config/application.rb\n# Line 15: config.autoload_paths << Rails.root.join('lib')\n# Rails 7.1+: lib/ autoloaded by default (config.autoload_lib)\n# Action: Remove manual path to avoid conflicts\n\n\nAsset Pipeline\n\n# Custom Sprockets processors detected\n# Files: lib/assets/processors/custom_minifier.rb\n# Rails 8.0+: Propshaft doesn't support processors\n# Action: Migrate to different approach or keep Sprockets\n\n\n\n\nWhat You Get\n\nEvery upgrade request generates a detailed report:\n\n1. Executive Summary\n\n\n Current and target versions\n Number of breaking changes\n Estimated time and risk assessment\n\n\n2. Project Analysis\n\n\n Your Rails version and structure\n Files that need updating\n Custom configurations detected\n\n\n3. Breaking Changes (Prioritized)\n\n\n HIGH Priority: Will cause app to fail\n MEDIUM Priority: Should address soon\n LOW Priority: Optional improvements\n\n\n4. Code Examples (OLD vs NEW)\n\n# OLD (Rails 7.2)\nconfig.action_dispatch.show_exceptions = true\n\n# NEW (Rails 7.2+)\nconfig.action_dispatch.show_exceptions = :all\n\n\n5. Step-by-Step Migration Guide\n\n\n Phase-by-phase breakdown\n Time estimates per phase\n Testing checkpoints\n\n\n6. Testing Checklist\n\n\n Unit test guidance\n Integration test scenarios\n Manual testing checklist\n\n\n\n\nPre-Upgrade Checklist\n\nBefore starting any upgrade:\n\nCritical:\n\n\n All tests currently passing\n Database backed up\n Application under version control\n Staging environment available\n Rollback plan documented\n\n\nImportant:\n\n\n Current version confirmed\n Dependencies reviewed for compatibility\n Custom code documented\n\n\n\n\nPackage Contents\n\nrails-upgrade-assistant/\n├── agents/rails-upgrade-assistant.md Main agent\n├── workflows/ How to generate deliverables\n├── examples/ Real usage scenarios\n├── reference/ Quick reference\n├── version-guides/ Rails version details\n├── templates/ Report templates\n└── detection-scripts/ Pattern definitions\n\n\nVersion Guides\n\n\n upgrade-6.0-to-6.1.md - 18 breaking changes\n upgrade-6.1-to-7.0.md - 17 breaking changes\n upgrade-7.0-to-7.1.md - 12 breaking changes\n upgrade-7.1-to-7.2.md - 38 breaking changes\n upgrade-7.2-to-8.0.md - 13 breaking changes\n upgrade-8.0-to-8.1.md - 8 breaking changes\n\n\n\n\nUsage Examples\n\nSimple Upgrade\n\n\"Upgrade my Rails app to 8.1\"\n\n\nWith Specific Details\n\n\"Upgrade my Rails app from 7.2 to 8.0\"\n\n\nRisk Assessment Only\n\n\"Assess upgrade impact from 7.2 to 8.0\"\n\n\nComponent-Specific Questions\n\n\"What ActiveRecord changes are in Rails 8.0?\"\n\"Show me all configuration file changes for 7.2\"\n\n\n\n\nNext Steps\n\n\n \n \n GitHub Repository\n \n \n View source code and contribute.\n \n \n\n \n \n Rails MCP Server\n \n \n Optional — static code analysis to complement upgrades." }, { "id": "documentation-ai-tools-spec-driven-development", "title": "Spec-Driven Development", "collection": { "label": "pages", "name": "Posts" }, "categories": "", "tags": "", "url": "/documentation/ai-tools/spec-driven-development/", "content": "A Claude Code plugin for Rails-focused spec-driven development. Shape a feature into a structured spec, break it into tasks, and hand it off to Claude Code for implementation — with a progress file that survives across sessions.\n\n\n\nWhat Is This?\n\nA Claude Code skill that turns rough feature ideas into implementation-ready specs:\n\n\n Plans your product from existing MVP documentation (mission, roadmap, tech stack)\n Shapes feature specs through targeted discovery questions\n Writes formal specifications with user stories, acceptance criteria, and scope boundaries\n Breaks each spec into ordered, self-contained tasks\n Discovers coding standards and tribal knowledge already present in the codebase\n Tracks progress in a YAML file so any session can resume where the last one stopped\n\n\nThe goal: features built to spec instead of code thrown together, with a paper trail you can point Claude at later.\n\n\n\nThe Workflow\n\n\n \n \n Step\n Command\n Output\n \n \n \n \n 1. Initialize\n /sdd-init\n sdd/ directory + progress.yml\n \n \n 2. Plan the product\n /sdd-plan\n mission.md, roadmap.md, tech-stack.md\n \n \n 3. Shape a feature\n /sdd-shape\n planning/requirements.md + formal spec.md\n \n \n 4. Break into tasks\n /sdd-tasks\n tasks.md with ordered task groups\n \n \n 5. Discover standards\n /sdd-discover-standards\n Extracted patterns in standards/\n \n \n 6. Check progress\n /sdd-status\n Current phase, completed items, next step\n \n \n\n\nSteps 1–2 are one-time setup. Steps 3–6 repeat per feature. You can also drive the workflow with natural language — the skill responds to “shape a spec for comments” or “what’s next?” without requiring the slash commands.\n\n\n\nQuick Start\n\n1. Add the Marketplace\n\n/plugin marketplace add maquina-app/rails-claude-code\n\n\n2. Install the Plugin\n\n/plugin install spec-driven-development@maquina\n\n\n3. Initialize in Your Project\n\n> /sdd-init\n\n\nThe skill creates the sdd/ directory, a progress file, and the folder structure the rest of the workflow depends on.\n\n4. Plan the Product\n\n> /sdd-plan\n\n\nIf MVP Creator documentation already exists in the project, the skill reuses it. Otherwise it asks you the product-planning questions directly.\n\n5. Shape Your First Feature\n\n> /sdd-shape user authentication\n\n\nThe skill asks the clarifying questions, writes requirements, and produces a spec ready for task breakdown.\n\n\n\nSlash Commands\n\n\n \n \n Command\n What It Does\n \n \n \n \n /sdd-init\n Bootstrap the sdd/ directory and progress.yml for a project\n \n \n /sdd-plan\n Create mission, roadmap, and tech-stack documents from MVP docs or discovery\n \n \n /sdd-shape\n Shape a feature into requirements and a formal spec\n \n \n /sdd-tasks\n Generate an ordered task breakdown from a spec\n \n \n /sdd-status\n Show current phase, completed steps, and next action\n \n \n /sdd-discover-standards\n Extract coding patterns and tribal knowledge from the codebase\n \n \n\n\n\n\nWhat You’ll Get\n\nEach feature lives in its own folder under sdd/specs/:\n\nsdd/\n├── progress.yml # Workflow state across sessions\n├── product/\n│ ├── mission.md # Product vision\n│ ├── roadmap.md # Feature priorities\n│ └── tech-stack.md # Technology choices\n├── standards/ # Coding standards discovered in your code\n│ ├── global/\n│ ├── backend/\n│ └── frontend/\n└── specs/\n └── 2026-04-16-user-auth/\n ├── planning/\n │ ├── requirements.md # Gathered requirements\n │ └── visuals/ # Mockups, wireframes (optional)\n ├── spec.md # Formal specification\n └── tasks.md # Ordered task breakdown\n\n\nSpecs are self-contained: once shaped, Claude can execute them without needing to replay the conversation that produced them.\n\n\n\nUsage Examples\n\nStart From an Existing MVP\n\n> /sdd-plan\n\n\nIf you ran MVP Creator earlier, the skill pulls directly from business-plan.md and technical-guide.md instead of asking the same questions again.\n\nShape a Feature From an Idea\n\n> I want to add comments to posts\n\n\nThe skill asks for scope, user roles, moderation rules, and related code — then writes requirements and a spec.\n\nBreak a Spec Into Tasks\n\n> /sdd-tasks for the user-auth spec\n\n\nProduces grouped tasks (database layer, models, controllers, views, tests) with acceptance criteria.\n\nResume After a Break\n\n> /sdd-status\n\n\nReads progress.yml and reports exactly where you left off — no context-replay needed.\n\nCapture Tribal Knowledge\n\n> /sdd-discover-standards\n\n\nThe skill scans your codebase for recurring patterns (naming conventions, controller structure, test style) and writes them into sdd/standards/ so future specs align with your existing code.\n\n\n\nPackage Contents\n\nspec-driven-development/\n├── README.md # Full documentation\n├── QUICKSTART.md # Quick reference\n├── commands/ # 6 slash commands\n│ ├── sdd-init.md\n│ ├── sdd-plan.md\n│ ├── sdd-shape.md\n│ ├── sdd-tasks.md\n│ ├── sdd-status.md\n│ └── sdd-discover-standards.md\n├── scripts/\n│ ├── init_sdd.sh # Bootstrap SDD structure\n│ ├── new_spec.sh # Create a new spec folder\n│ └── status.sh # Show progress\n├── templates/\n│ ├── standard-template.md # Spec template\n│ └── progress.yml # Progress-tracking file\n└── skills/spec-driven-development/\n ├── SKILL.md # Main skill (routes to the slash commands)\n └── references/\n ├── rails-standards.md # Rails conventions\n ├── hotwire-patterns.md # Turbo/Stimulus patterns\n └── document-templates.md # Spec templates\n\n\n\n\nTeam Installation\n\nAdd to your project’s .claude/settings.json:\n\n{\n \"extraKnownMarketplaces\": {\n \"maquina\": {\n \"source\": {\n \"source\": \"github\",\n \"repo\": \"maquina-app/rails-claude-code\"\n }\n }\n },\n \"enabledPlugins\": [\n \"spec-driven-development@maquina\"\n ]\n}\n\n\nCommit sdd/ alongside your code. Every teammate — human or AI — picks up the same plan, specs, and standards.\n\n\n\nNext Steps\n\n\n \n \n GitHub Repository\n \n \n View source code and contribute.\n \n \n\n \n \n MVP Creator\n \n \n Upstream handoff: go from idea to MVP documentation.\n \n \n\n \n \n Maquina UI Standards\n \n \n Build the views described in your specs with consistent components." }, { "id": "documentation-components-alert", "title": "Alert", "collection": { "label": "pages", "name": "Posts" }, "categories": "", "tags": "", "url": "/documentation/components/alert/", "content": "Preview\n \n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n \n \n default\n \n\n \n \n\n \n \n\n \n \n \n \n\n\n\n\nUsage\n\n<%= render "components/alert", icon: :info do %>\n <%= render "components/alert/title", text: "Heads up!" %>\n <%= render "components/alert/description", text: "You can add components using the CLI." %>\n<% end %>\n\nExamples\n\nDestructive\n\n\n\n \n \n Preview\n \n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n \n \n default\n \n\n \n \n\n \n \n\n \n \n \n \n\n\n\n\n<%= render "components/alert", variant: :destructive, icon: :triangle_alert do %>\n <%= render "components/alert/title", text: "Error" %>\n <%= render "components/alert/description", text: "Your session has expired." %>\n<% end %>\n\nSuccess\n\n\n\n \n \n Preview\n \n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n \n \n default\n \n\n \n \n\n \n \n\n \n \n \n \n\n\n\n\n<%= render "components/alert", variant: :success, icon: :check_circle do %>\n <%= render "components/alert/title", text: "Success" %>\n <%= render "components/alert/description", text: "Your changes have been saved." %>\n<% end %>\n\nInfo\n\n\n\n \n \n Preview\n \n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n \n \n default\n \n\n \n \n\n \n \n\n \n \n \n \n\n\n\n\n<%= render "components/alert", variant: :info, icon: :info do %>\n <%= render "components/alert/title", text: "Heads up" %>\n <%= render "components/alert/description", text: "This release normalizes the default radius." %>\n<% end %>\n\nWarning\n\n\n\n \n \n Preview\n \n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n \n \n default\n \n\n \n \n\n \n \n\n \n \n \n \n\n\n\n\n<%= render "components/alert", variant: :warning, icon: :triangle_alert do %>\n <%= render "components/alert/title", text: "Warning" %>\n <%= render "components/alert/description", text: "This action cannot be undone." %>\n<% end %>\n\nAPI Reference\n\nAlert\n\n\n \n \n Parameter\n Type\n Default\n Description\n \n \n \n \n variant\n Symbol\n :default\n :default, :destructive, :success, :warning, :info; :error is accepted as an alias of :destructive\n \n \n icon\n Symbol\n nil\n Icon name to display\n \n \n css_classes\n String\n \"\"\n Additional CSS classes\n \n \n html_options\n Hash\n {}\n Additional HTML attributes\n \n \n\n\nCustom icon markup\n\nicon: renders a built-in glyph as the alert's first child, which is what the variant icon colors key off. If you need your own markup instead — an inline SVG, an icon font, an <img>, or an icon that is not the first child — mark it with data-alert-part="icon" and it picks up the same sizing and per-variant color:\n\n<%= render "components/alert", variant: :success do %>\n <span data-alert-part="icon"><%= image_tag "check.svg" %></span>\n <%= render "components/alert/title", text: "Saved" %>\n<% end %>\n\nPass data: { has_icon: true } alongside it so the alert reserves the left padding it normally adds for icon:.\n\n\nAlert Title\n\n\n \n \n Parameter\n Type\n Default\n Description\n \n \n \n \n text\n String\n nil\n Title text\n \n \n content\n String\n nil\n HTML content via capture, or use block\n \n \n css_classes\n String\n \"\"\n Additional CSS classes\n \n \n html_options\n Hash\n {}\n Additional HTML attributes\n \n \n\n\n\nAlert Description\n\n\n \n \n Parameter\n Type\n Default\n Description\n \n \n \n \n text\n String\n nil\n Description text\n \n \n content\n String\n nil\n HTML content via capture, or use block\n \n \n css_classes\n String\n \"\"\n Additional CSS classes\n \n \n html_options\n Hash\n {}\n Additional HTML attributes" }, { "id": "documentation-components-badge", "title": "Badge", "collection": { "label": "pages", "name": "Posts" }, "categories": "", "tags": "", "url": "/documentation/components/badge/", "content": "Preview\n \n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n \n \n default\n \n\n \n \n\n \n \n\n \n \n \n \n\n\n\n\nUsage\n\n<%= render "components/badge" do %>\n Badge\n<% end %>\n\nExamples\n\nVariants\n\n\n\n \n \n Preview\n \n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n \n \n default\n \n\n \n \n\n \n \n\n \n \n \n \n\n\n\n\n<%= render "components/badge", variant: :primary do %>Primary<% end %>\n<%= render "components/badge", variant: :secondary do %>Secondary<% end %>\n<%= render "components/badge", variant: :destructive do %>Destructive<% end %>\n<%= render "components/badge", variant: :success do %>Success<% end %>\n<%= render "components/badge", variant: :warning do %>Warning<% end %>\n<%= render "components/badge", variant: :info do %>Info<% end %>\n<%= render "components/badge", variant: :outline do %>Outline<% end %>\n\nSizes\n\n\n\n \n \n Preview\n \n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n \n \n default\n \n\n \n \n\n \n \n\n \n \n \n \n\n\n\n\n<%= render "components/badge", size: :sm do %>Small<% end %>\n<%= render "components/badge", size: :md do %>Medium<% end %>\n<%= render "components/badge", size: :lg do %>Large<% end %>\n\nWith Icons\n\n\n\n \n \n Preview\n \n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n \n \n default\n \n\n \n \n\n \n \n\n \n \n \n \n\n\n\n\n<%= render "components/badge", variant: :success do %>\n <%= icon_for :check, class: "size-3" %>\n Verified\n<% end %>\n\n<%= render "components/badge", variant: :warning do %>\n <%= icon_for :clock, class: "size-3" %>\n Pending\n<% end %>\n\nAPI Reference\n\nBadge\n\n\n \n \n Parameter\n Type\n Default\n Description\n \n \n \n \n variant\n Symbol\n :default\n :default, :primary, :secondary, :destructive, :success, :warning, :info, :outline; :error is accepted as an alias of :destructive\n \n \n size\n Symbol\n :md\n :sm, :md, :lg; :default is accepted as an alias of :md\n \n \n css_classes\n String\n \"\"\n Additional CSS classes\n \n \n html_options\n Hash\n {}\n Additional HTML attributes" }, { "id": "documentation-components-breadcrumbs", "title": "Breadcrumbs", "collection": { "label": "pages", "name": "Posts" }, "categories": "", "tags": "", "url": "/documentation/components/breadcrumbs/", "content": "Preview\n \n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n \n \n default\n \n\n \n \n\n \n \n\n \n \n \n \n\n\n\n\nUsage\n\n<%= render "components/breadcrumbs" do %>\n <%= render "components/breadcrumbs/list" do %>\n <%= render "components/breadcrumbs/item" do %>\n <%= render "components/breadcrumbs/link", href: "/" do %>Home<% end %>\n <% end %>\n <%= render "components/breadcrumbs/separator" %>\n <%= render "components/breadcrumbs/item" do %>\n <%= render "components/breadcrumbs/link", href: "/components" do %>Components<% end %>\n <% end %>\n <%= render "components/breadcrumbs/separator" %>\n <%= render "components/breadcrumbs/item" do %>\n <%= render "components/breadcrumbs/page" do %>Breadcrumbs<% end %>\n <% end %>\n <% end %>\n<% end %>\n\nUsing Helper\n\n<%= breadcrumbs({"Home" => root_path, "Users" => users_path}, "John Doe") %>\n\nExamples\n\nWith Icons\n\n\n\n \n \n Preview\n \n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n \n \n default\n \n\n \n \n\n \n \n\n \n \n \n \n\n\n\n\n<%= render "components/breadcrumbs/link", href: "/" do %>\n <%= icon_for(:home, class: "size-4") %>\n Home\n<% end %>\n\nCustom Separators\n\n\n\n \n \n Preview\n \n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n \n \n default\n \n\n \n \n\n \n \n\n \n \n \n \n\n\n\n\n<%= render "components/breadcrumbs/separator", icon: :slash %>\n<%= render "components/breadcrumbs/separator", icon: :arrow_right %>\n\nWith Ellipsis\n\n\n\n \n \n Preview\n \n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n \n \n default\n \n\n \n \n\n \n \n\n \n \n \n \n\n\n\n\n<%= render "components/breadcrumbs/item" do %>\n <%= render "components/breadcrumbs/ellipsis" %>\n<% end %>\n\nResponsive\n\n<%= responsive_breadcrumbs(\n {"Home" => "/", "Docs" => "/docs", "Components" => "/components"},\n "Breadcrumbs"\n) %>\n\nItems collapse only when they do not fit, and come back when they do. The controller measures the trail against its container and hides middle items one at a time, from the first one inward, until the row fits — so the ellipsis always stands for the items directly behind it. Widen the container and the hidden items return; there is no one-way collapse and no item-count threshold.\n\nThe container is what is measured, not the window, so a breadcrumb inside a collapsing sidebar or a resizing panel re-fits when that panel moves. If a single current-page title is too long to help by collapsing anything, it truncates with an ellipsis as a last resort.\n\nThe ellipsis dropdown\n\nWhen items are collapsed, the … becomes a button. Clicking it opens a menu listing the hidden items as links, so nothing in the trail becomes unreachable. It renders in the top layer as a popover — light dismiss and Escape work natively — and needs no markup from you beyond responsive_breadcrumbs.\n\nAPI Reference\n\nBreadcrumbs\n\n\n \n \n Parameter\n Type\n Default\n Description\n \n \n \n \n responsive\n Boolean\n false\n Collapse middle items when the trail does not fit its container, and restore them when it does\n \n \n collapse_after\n Integer\n —\n Deprecated, ignored. Removed in 0.8.0. It collapsed on item count without consulting available width, which also collapsed a trail with room to spare; space-based collapsing works now\n \n \n css_classes\n String\n \"\"\n Additional CSS classes\n \n \n html_options\n Hash\n {}\n Additional HTML attributes\n \n \n\n\n\nBreadcrumbs List\n\n\n \n \n Parameter\n Type\n Default\n Description\n \n \n \n \n css_classes\n String\n \"\"\n Additional CSS classes\n \n \n html_options\n Hash\n {}\n Additional HTML attributes\n \n \n\n\n\nBreadcrumbs Item\n\n\n \n \n Parameter\n Type\n Default\n Description\n \n \n \n \n css_classes\n String\n \"\"\n Additional CSS classes\n \n \n html_options\n Hash\n {}\n Additional HTML attributes\n \n \n\n\n\nBreadcrumbs Link\n\n\n \n \n Parameter\n Type\n Default\n Description\n \n \n \n \n href\n String\n required\n Link destination\n \n \n css_classes\n String\n \"\"\n Additional CSS classes\n \n \n html_options\n Hash\n {}\n Additional HTML attributes\n \n \n\n\n\nBreadcrumbs Page\n\n\n \n \n Parameter\n Type\n Default\n Description\n \n \n \n \n css_classes\n String\n \"\"\n Additional CSS classes\n \n \n html_options\n Hash\n {}\n Additional HTML attributes\n \n \n\n\n\nBreadcrumbs Separator\n\n\n \n \n Parameter\n Type\n Default\n Description\n \n \n \n \n icon\n Symbol\n :chevron_right\n Icon name, or :custom to use block\n \n \n css_classes\n String\n \"\"\n Additional CSS classes\n \n \n html_options\n Hash\n {}\n Additional HTML attributes\n \n \n\n\n\nBreadcrumbs Ellipsis\n\n\n \n \n Parameter\n Type\n Default\n Description\n \n \n \n \n css_classes\n String\n \"\"\n Additional CSS classes\n \n \n html_options\n Hash\n {}\n Additional HTML attributes" }, { "id": "documentation-components-calendar", "title": "Calendar", "collection": { "label": "pages", "name": "Posts" }, "categories": "", "tags": "", "url": "/documentation/components/calendar/", "content": "Preview\n \n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n \n \n default\n \n\n \n \n\n \n \n\n \n \n \n \n\n\n\n\nUsage\n\n<%= render "components/calendar" %>\n\nWith Selected Date\n\n<%= render "components/calendar", selected: Date.today %>\n\nExamples\n\nSingle Selection\n\n\n\n \n \n Preview\n \n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n \n \n default\n \n\n \n \n\n \n \n\n \n \n \n \n\n\n\n\n<%= render "components/calendar",\n mode: :single,\n selected: Date.today %>\n\nRange Selection\n\n\n\n \n \n Preview\n \n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n \n \n default\n \n\n \n \n\n \n \n\n \n \n \n \n\n\n\n\n<%= render "components/calendar",\n mode: :range,\n selected: Date.today,\n selected_end: Date.today + 5 %>\n\nWith Date Constraints\n\n\n\n \n \n Preview\n \n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n \n \n default\n \n\n \n \n\n \n \n\n \n \n \n \n\n\n\n\n<%= render "components/calendar",\n min_date: Date.today,\n max_date: Date.today + 14 %>\n\nWeek Starting Monday\n\n\n\n \n \n Preview\n \n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n \n \n default\n \n\n \n \n\n \n \n\n \n \n \n \n\n\n\n\n<%= render "components/calendar",\n week_starts_on: :monday %>\n\nForm Integration\n\n<%= form_with model: @event do |f| %>\n <%= render "components/calendar",\n selected: @event.date,\n input_name: "event[date]" %>\n<% end %>\n\nRange Form Integration\n\n<%= render "components/calendar",\n mode: :range,\n input_name: "booking[check_in]",\n input_name_end: "booking[check_out]" %>\n\nAPI Reference\n\nCalendar\n\n\n \n \n Parameter\n Type\n Default\n Description\n \n \n \n \n selected\n Date, String\n nil\n Selected start date\n \n \n selected_end\n Date, String\n nil\n Selected end date (range mode)\n \n \n month\n Integer\n nil\n Display month (1-12)\n \n \n year\n Integer\n nil\n Display year\n \n \n mode\n Symbol\n :single\n :single or :range\n \n \n min_date\n Date, String\n nil\n Minimum selectable date\n \n \n max_date\n Date, String\n nil\n Maximum selectable date\n \n \n disabled_dates\n Array\n []\n Dates to disable\n \n \n show_outside_days\n Boolean\n true\n Show days from adjacent months\n \n \n week_starts_on\n Symbol\n :sunday\n :sunday or :monday\n \n \n cell_size\n String\n nil\n Custom cell size CSS value\n \n \n input_name\n String\n nil\n Hidden input name for forms\n \n \n input_name_end\n String\n nil\n End date hidden input name\n \n \n css_classes\n String\n \"\"\n Additional CSS classes\n \n \n html_options\n Hash\n {}\n Additional HTML attributes\n \n \n\n\n\nCalendar Header\n\n\n \n \n Parameter\n Type\n Default\n Description\n \n \n \n \n month\n Integer\n required\n Display month\n \n \n year\n Integer\n required\n Display year\n \n \n month_name\n String\n required\n Formatted month name\n \n \n css_classes\n String\n \"\"\n Additional CSS classes\n \n \n html_options\n Hash\n {}\n Additional HTML attributes" }, { "id": "documentation-components-card", "title": "Card", "collection": { "label": "pages", "name": "Posts" }, "categories": "", "tags": "", "url": "/documentation/components/card/", "content": "Preview\n \n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n \n \n default\n \n\n \n \n\n \n \n\n \n \n \n \n\n\n\n\nUsage\n\n<%= render "components/card" do %>\n <%= render "components/card/header" do %>\n <%= render "components/card/title", text: "Card Title" %>\n <%= render "components/card/description", text: "Card description." %>\n <% end %>\n <%= render "components/card/content" do %>\n <p>Card content goes here.</p>\n <% end %>\n <%= render "components/card/footer" do %>\n <button data-component="button" data-variant="primary">Save</button>\n <% end %>\n<% end %>\n\nExamples\n\nSimple Card\n\n\n\n \n \n Preview\n \n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n \n \n default\n \n\n \n \n\n \n \n\n \n \n \n \n\n\n\n\n<%= render "components/card" do %>\n <%= render "components/card/content", spacing: :full do %>\n <p>A simple card with just content.</p>\n <% end %>\n<% end %>\n\nWith Header Action\n\n\n\n \n \n Preview\n \n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n \n \n default\n \n\n \n \n\n \n \n\n \n \n \n \n\n\n\n\n<%= render "components/card" do %>\n <%= render "components/card/header", layout: :row do %>\n <div>\n <%= render "components/card/title", text: "Team Members" %>\n <%= render "components/card/description", text: "Manage your team." %>\n </div>\n <%= render "components/card/action" do %>\n <button data-component="button" data-variant="primary" data-size="sm">Add</button>\n <% end %>\n <% end %>\n <%= render "components/card/content" do %>\n <p class="text-sm text-muted-foreground">No members yet.</p>\n <% end %>\n<% end %>\n\nWith Footer\n\n\n\n \n \n Preview\n \n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n \n \n default\n \n\n \n \n\n \n \n\n \n \n \n \n\n\n\n\n<%= render "components/card" do %>\n <%= render "components/card/header" do %>\n <%= render "components/card/title", text: "Settings" %>\n <% end %>\n <%= render "components/card/content" do %>\n <p>Configure your preferences.</p>\n <% end %>\n <%= render "components/card/footer", align: :end do %>\n <button data-component="button" data-variant="outline">Cancel</button>\n <button data-component="button" data-variant="primary">Save</button>\n <% end %>\n<% end %>\n\nAPI Reference\n\nCard\n\n\n \n \n Parameter\n Type\n Default\n Description\n \n \n \n \n css_classes\n String\n \"\"\n Additional CSS classes\n \n \n html_options\n Hash\n {}\n Additional HTML attributes\n \n \n\n\n\nCard Header\n\n\n \n \n Parameter\n Type\n Default\n Description\n \n \n \n \n layout\n Symbol\n :column\n :column or :row\n \n \n css_classes\n String\n \"\"\n Additional CSS classes\n \n \n html_options\n Hash\n {}\n Additional HTML attributes\n \n \n\n\n\nCard Title\n\n\n \n \n Parameter\n Type\n Default\n Description\n \n \n \n \n text\n String\n nil\n Title text\n \n \n content\n String\n nil\n HTML content via capture, or use block\n \n \n size\n Symbol\n :default\n :default or :sm\n \n \n css_classes\n String\n \"\"\n Additional CSS classes\n \n \n html_options\n Hash\n {}\n Additional HTML attributes\n \n \n\n\n\nCard Description\n\n\n \n \n Parameter\n Type\n Default\n Description\n \n \n \n \n text\n String\n nil\n Description text\n \n \n content\n String\n nil\n HTML content via capture, or use block\n \n \n css_classes\n String\n \"\"\n Additional CSS classes\n \n \n html_options\n Hash\n {}\n Additional HTML attributes\n \n \n\n\n\nCard Action\n\n\n \n \n Parameter\n Type\n Default\n Description\n \n \n \n \n css_classes\n String\n \"\"\n Additional CSS classes\n \n \n html_options\n Hash\n {}\n Additional HTML attributes\n \n \n\n\n\nCard Content\n\n\n \n \n Parameter\n Type\n Default\n Description\n \n \n \n \n spacing\n Symbol\n :default\n :default or :full (when no header)\n \n \n css_classes\n String\n \"\"\n Additional CSS classes\n \n \n html_options\n Hash\n {}\n Additional HTML attributes\n \n \n\n\n\nCard Footer\n\n\n \n \n Parameter\n Type\n Default\n Description\n \n \n \n \n align\n Symbol\n :start\n :start, :center, :end, :between\n \n \n spacing\n Symbol\n :default\n :default or :full (when no content)\n \n \n css_classes\n String\n \"\"\n Additional CSS classes\n \n \n html_options\n Hash\n {}\n Additional HTML attributes" }, { "id": "documentation-components-combobox", "title": "Combobox", "collection": { "label": "pages", "name": "Posts" }, "categories": "", "tags": "", "url": "/documentation/components/combobox/", "content": "Preview\n \n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n \n \n default\n \n\n \n \n\n \n \n\n \n \n \n \n\n\n\n\nThe trigger renders its own up/down chevron, and the search field inside the popover carries a focus ring like any other form control.\n\nUsage\n\n<%= render "components/combobox", placeholder: "Select..." do |combobox_id| %>\n <%= render "components/combobox/trigger", for_id: combobox_id, placeholder: "Select..." %>\n\n <%= render "components/combobox/content", id: combobox_id do %>\n <%= render "components/combobox/input", placeholder: "Search..." %>\n\n <%= render "components/combobox/list" do %>\n <%= render "components/combobox/option", value: "one" do %>Option One<% end %>\n <%= render "components/combobox/option", value: "two" do %>Option Two<% end %>\n <% end %>\n\n <%= render "components/combobox/empty" %>\n <% end %>\n<% end %>\n\nExamples\n\nWith Selection\n\n\n\n \n \n Preview\n \n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n \n \n default\n \n\n \n \n\n \n \n\n \n \n \n \n\n\n\n\n<%= render "components/combobox/option", value: "active", selected: true do %>Active<% end %>\n<%= render "components/combobox/option", value: "archived", disabled: true do %>Archived<% end %>\n\nWith Groups\n\n\n\n \n \n Preview\n \n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n \n \n default\n \n\n \n \n\n \n \n\n \n \n \n \n\n\n\n\n<%= render "components/combobox/list" do %>\n <%= render "components/combobox/group" do %>\n <%= render "components/combobox/label", text: "Backend" %>\n <%= render "components/combobox/option", value: "ruby" do %>Ruby<% end %>\n <% end %>\n\n <%= render "components/combobox/separator" %>\n\n <%= render "components/combobox/group" do %>\n <%= render "components/combobox/label", text: "Frontend" %>\n <%= render "components/combobox/option", value: "js" do %>JavaScript<% end %>\n <% end %>\n<% end %>\n\nAPI Reference\n\nCombobox\n\n\n \n \n Parameter\n Type\n Default\n Description\n \n \n \n \n id\n String\n nil\n Custom ID; defaults to a deterministic id derived from the input name\n \n \n name\n String\n nil\n Form input name\n \n \n value\n String\n nil\n Pre-selected value\n \n \n placeholder\n String\n \"Select...\"\n Placeholder text\n \n \n css_classes\n String\n \"\"\n Additional CSS classes\n \n \n html_options\n Hash\n {}\n Additional HTML attributes\n \n \n\n\n\nCombobox Trigger\n\n\n \n \n Parameter\n Type\n Default\n Description\n \n \n \n \n for_id\n String\n required\n ID of content popover\n \n \n placeholder\n String\n \"Select...\"\n Placeholder text\n \n \n variant\n Symbol\n :outline\n Button variant\n \n \n size\n Symbol\n :default\n Button size\n \n \n css_classes\n String\n \"\"\n Additional CSS classes\n \n \n html_options\n Hash\n {}\n Additional HTML attributes\n \n \n\n\n\nCombobox Content\n\n\n \n \n Parameter\n Type\n Default\n Description\n \n \n \n \n id\n String\n required\n Popover ID\n \n \n align\n Symbol\n :start\n :start, :center, :end\n \n \n width\n Symbol\n :default\n :sm, :default, :md, :lg, :full\n \n \n css_classes\n String\n \"\"\n Additional CSS classes\n \n \n html_options\n Hash\n {}\n Additional HTML attributes\n \n \n\n\n\nCombobox Input\n\n\n \n \n Parameter\n Type\n Default\n Description\n \n \n \n \n placeholder\n String\n \"Search...\"\n Search placeholder\n \n \n css_classes\n String\n \"\"\n Additional CSS classes\n \n \n html_options\n Hash\n {}\n Additional HTML attributes\n \n \n\n\n\nCombobox Option\n\n\n \n \n Parameter\n Type\n Default\n Description\n \n \n \n \n value\n String\n required\n Option value\n \n \n selected\n Boolean\n false\n Whether selected\n \n \n disabled\n Boolean\n false\n Whether disabled\n \n \n css_classes\n String\n \"\"\n Additional CSS classes\n \n \n html_options\n Hash\n {}\n Additional HTML attributes\n \n \n\n\n\nCombobox Empty\n\n\n \n \n Parameter\n Type\n Default\n Description\n \n \n \n \n text\n String\n \"No results found.\"\n Empty state message\n \n \n content\n String\n nil\n Captured HTML via capture, or use block\n \n \n css_classes\n String\n \"\"\n Additional CSS classes\n \n \n html_options\n Hash\n {}\n Additional HTML attributes\n \n \n\n\n\nCombobox Group\n\n\n \n \n Parameter\n Type\n Default\n Description\n \n \n \n \n css_classes\n String\n \"\"\n Additional CSS classes\n \n \n html_options\n Hash\n {}\n Additional HTML attributes\n \n \n\n\n\nCombobox Label\n\n\n \n \n Parameter\n Type\n Default\n Description\n \n \n \n \n text\n String\n nil\n Label text\n \n \n content\n String\n nil\n HTML content via capture, or use block\n \n \n css_classes\n String\n \"\"\n Additional CSS classes\n \n \n html_options\n Hash\n {}\n Additional HTML attributes\n \n \n\n\n\nCombobox List\n\n\n \n \n Parameter\n Type\n Default\n Description\n \n \n \n \n css_classes\n String\n \"\"\n Additional CSS classes\n \n \n html_options\n Hash\n {}\n Additional HTML attributes\n \n \n\n\nBuilder Helper\n\nThe combobox helper wires the trigger and content ids together for you:\n\n<%= combobox placeholder: "Select framework...", name: "framework" do |cb| %>\n <% cb.trigger %>\n <% cb.content do %>\n <% cb.input placeholder: "Search..." %>\n <% cb.list do %>\n <% cb.group do %>\n <% cb.label "Frontend" %>\n <% cb.option value: "react" do %>React<% end %>\n <% cb.option value: "vue" do %>Vue<% end %>\n <% end %>\n <% cb.separator %>\n <% cb.option value: "rails" do %>Rails<% end %>\n <% end %>\n <% cb.empty %>\n <% end %>\n<% end %>\n\nFor flat option lists, combobox_simple renders everything from data:\n\n<%= combobox_simple placeholder: "Select framework...",\n name: "framework",\n options: [\n { value: "nextjs", label: "Next.js" },\n { value: "remix", label: "Remix" }\n ] %>" }, { "id": "documentation-components-date-picker", "title": "Date Picker", "collection": { "label": "pages", "name": "Posts" }, "categories": "", "tags": "", "url": "/documentation/components/date-picker/", "content": "Preview\n \n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n \n \n default\n \n\n \n \n\n \n \n\n \n \n \n \n\n\n\n\nUsage\n\n<%= render "components/date_picker",\n mode: :single,\n placeholder: "Select a date",\n input_name: "event_date" %>\n\nExamples\n\nRange Selection\n\n\n\n \n \n Preview\n \n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n \n \n default\n \n\n \n \n\n \n \n\n \n \n \n \n\n\n\n\n<%= render "components/date_picker",\n mode: :range,\n placeholder: "Select date range",\n input_name: "start_date",\n input_name_end: "end_date" %>\n\nWith Pre-selected Date\n\n\n\n \n \n Preview\n \n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n \n \n default\n \n\n \n \n\n \n \n\n \n \n \n \n\n\n\n\n<%= render "components/date_picker",\n mode: :single,\n selected: Date.today,\n input_name: "event_date" %>\n\nWith Date Constraints\n\n\n\n \n \n Preview\n \n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n \n \n default\n \n\n \n \n\n \n \n\n \n \n \n \n\n\n\n\n<%= render "components/date_picker",\n min_date: Date.today,\n max_date: Date.today + 30,\n placeholder: "Select within 30 days" %>\n\nDisabled\n\n\n\n \n \n Preview\n \n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n \n \n default\n \n\n \n \n\n \n \n\n \n \n \n \n\n\n\n\n<%= render "components/date_picker",\n selected: Date.today,\n disabled: true %>\n\nAPI Reference\n\nDate Picker\n\n\n \n \n Parameter\n Type\n Default\n Description\n \n \n \n \n selected\n Date, String\n nil\n Pre-selected date\n \n \n selected_end\n Date, String\n nil\n End date for range mode\n \n \n mode\n Symbol\n :single\n :single or :range\n \n \n min_date\n Date, String\n nil\n Minimum selectable date\n \n \n max_date\n Date, String\n nil\n Maximum selectable date\n \n \n disabled_dates\n Array\n []\n Array of dates to disable\n \n \n show_outside_days\n Boolean\n true\n Show days from adjacent months\n \n \n week_starts_on\n Symbol\n :sunday\n :sunday or :monday\n \n \n placeholder\n String\n nil\n Placeholder text\n \n \n input_name\n String\n nil\n Name for hidden form input\n \n \n input_name_end\n String\n nil\n End date input name (range mode)\n \n \n id\n String\n nil\n Custom ID; defaults to a deterministic id derived from the input name\n \n \n disabled\n Boolean\n false\n Whether disabled\n \n \n required\n Boolean\n false\n Mark input as required\n \n \n css_classes\n String\n \"\"\n Additional CSS classes\n \n \n html_options\n Hash\n {}\n Additional HTML attributes\n \n \n\n\n\nTurbo Drive\n\nThe date picker controller automatically closes the popover before Turbo caches the page. No configuration is needed — pressing the browser back button will always show the date picker in its closed state." }, { "id": "documentation-components-drawer", "title": "Drawer", "collection": { "label": "pages", "name": "Posts" }, "categories": "", "tags": "", "url": "/documentation/components/drawer/", "content": "Preview\n \n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n \n \n default\n \n\n \n \n\n \n \n\n \n \n \n \n\n\n\n\nUsage\n\n<%= render "components/drawer/provider", default_open: drawer_open? do %>\n <%= render "components/drawer", state: drawer_state do %>\n <%= render "components/drawer/header" do %>\n <%= render "components/drawer/title", text: "Drawer Title" %>\n <% end %>\n\n <%= render "components/drawer/content" do %>\n Drawer content\n <% end %>\n\n <%= render "components/drawer/footer" do %>\n Footer actions\n <% end %>\n <% end %>\n<% end %>\n\nExamples\n\nWith Trigger\n\nThe trigger can live anywhere on the page — it finds the drawer through a Stimulus outlet and mirrors its state with aria-expanded.\n\n<%= render "components/drawer/trigger" do %>Open Drawer<% end %>\n\n<%= render "components/drawer/provider" do %>\n <%= render "components/drawer" do %>\n <!-- content -->\n <% end %>\n<% end %>\n\nSections and Separators\n\nGroup the drawer body into stacked sections, divided by a separator.\n\n<%= render "components/drawer/content" do %>\n <%= render "components/drawer/section" do %>\n <%= render "components/drawer/title", text: "Filters", tag: :h3 %>\n <% end %>\n\n <%= render "components/drawer/separator" %>\n\n <%= render "components/drawer/section" do %>\n <%# More rows %>\n <% end %>\n<% end %>\n\ndrawer/separator renders the separator primitive, so it keeps the primitive's 1px track while the drawer part re-spaces it for the panel.\n\nLeft Side Drawer\n\n\n\n \n \n Preview\n \n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n \n \n default\n \n\n \n \n\n \n \n\n \n \n \n \n\n\n\n\n<%= render "components/drawer", side: :left do %>\n <!-- content -->\n<% end %>\n\nKeyboard & Accessibility\n\n\n Cmd/Ctrl + D toggles the drawer (configurable via keyboard_shortcut).\n Escape closes it; clicking the backdrop closes it.\n The panel is a role="dialog" with aria-modal and a configurable aria_label. Focus moves into the panel on open and returns to the previously focused element on close.\n While closed, the panel is aria-hidden and inert, so the off-screen content is invisible to assistive technology and unreachable by keyboard.\n\n\nAPI Reference\n\nProvider\n\n\n \n \n Parameter\n Type\n Default\n Description\n \n \n \n \n id\n String\n \"drawer-provider\"\n Element ID for stable morph matching\n \n \n default_open\n Boolean\n false\n Initial open state\n \n \n cookie_name\n String\n \"drawer_state\"\n Cookie for persistence\n \n \n keyboard_shortcut\n String\n \"d\"\n Toggle shortcut (Cmd/Ctrl + key)\n \n \n css_classes\n String\n \"\"\n Additional CSS classes\n \n \n html_options\n Hash\n {}\n Additional HTML attributes\n \n \n\n\nDrawer\n\n\n \n \n Parameter\n Type\n Default\n Description\n \n \n \n \n id\n String\n auto\n Element ID (deterministic, derived from side)\n \n \n state\n Symbol\n :closed\n :open or :closed\n \n \n side\n Symbol\n :right\n :left or :right\n \n \n aria_label\n String\n \"Drawer\"\n Accessible name for the dialog panel\n \n \n css_classes\n String\n \"\"\n Additional CSS classes\n \n \n html_options\n Hash\n {}\n Additional HTML attributes\n \n \n\n\nTrigger\n\n\n \n \n Parameter\n Type\n Default\n Description\n \n \n \n \n icon_name\n Symbol\n nil\n Optional icon for the toggle button\n \n \n variant\n Symbol\n :default\n Button variant\n \n \n size\n Symbol\n :default\n Button size\n \n \n css_classes\n String\n \"\"\n Additional CSS classes\n \n \n html_options\n Hash\n {}\n Additional HTML attributes\n \n \n\n\nOther Parts\n\n\n \n \n Partial\n Description\n \n \n \n \n drawer/header\n Top section with title and built-in close button\n \n \n drawer/title\n Heading inside the header. text: / content: / block, tag: (default :h2)\n \n \n drawer/description\n Supporting line under the title. text: / content: / block, tag: (default :p)\n \n \n drawer/content\n Scrollable middle section\n \n \n drawer/footer\n Bottom section for actions\n \n \n drawer/section\n Groups related rows inside the content area. Container — pass a block\n \n \n drawer/separator\n Divider between sections. orientation: (default :horizontal)\n \n \n drawer/close\n Close button (X icon)\n \n \n\n\nHelper Methods\n\n\n \n \n Method\n Description\n \n \n \n \n drawer_state(cookie_name)\n Returns :open or :closed from the cookie\n \n \n drawer_open?(cookie_name)\n Returns true if open\n \n \n drawer_closed?(cookie_name)\n Returns true if closed\n \n \n\n\nTurbo Drive\n\nThe drawer controller integrates with Turbo Drive to keep state correct across navigations:\n\n\n Cache teardown: the drawer closes and the backdrop hides before Turbo caches the page.\n Morph awareness: with turbo_refresh_method_tag :morph, the drawer re-reads its cookie so client state survives the morph.\n Persistence: open/closed state lives in a cookie and survives full page loads." }, { "id": "documentation-components-dropdown-menu", "title": "Dropdown Menu", "collection": { "label": "pages", "name": "Posts" }, "categories": "", "tags": "", "url": "/documentation/components/dropdown-menu/", "content": "Preview\n \n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n \n \n default\n \n\n \n \n\n \n \n\n \n \n \n \n\n\n\n\nUsage\n\n<%= render "components/dropdown_menu" do %>\n <%= render "components/dropdown_menu/trigger" do %>Open Menu<% end %>\n\n <%= render "components/dropdown_menu/content" do %>\n <%= render "components/dropdown_menu/item", href: "#" do %>Profile<% end %>\n <%= render "components/dropdown_menu/item", href: "#" do %>Settings<% end %>\n <%= render "components/dropdown_menu/separator" %>\n <%= render "components/dropdown_menu/item", href: "#" do %>Logout<% end %>\n <% end %>\n<% end %>\n\nExamples\n\nWith Icons\n\n\n\n \n \n Preview\n \n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n \n \n default\n \n\n \n \n\n \n \n\n \n \n \n \n\n\n\n\n<%= render "components/dropdown_menu/item", href: "#" do %>\n <%= icon_for :user, class: "size-4" %>\n Profile\n<% end %>\n<%= render "components/dropdown_menu/item", href: "#", variant: :destructive do %>\n <%= icon_for :log_out, class: "size-4" %>\n Logout\n<% end %>\n\nWith Shortcuts\n\n\n\n \n \n Preview\n \n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n \n \n default\n \n\n \n \n\n \n \n\n \n \n \n \n\n\n\n\n<%= render "components/dropdown_menu/item", href: "#" do %>\n Undo\n <%= render "components/dropdown_menu/shortcut" do %>⌘Z<% end %>\n<% end %>\n\nIcon Trigger\n\nThe default trigger renders its own chevron, which rotates 180° while the menu is open. Reach for as_child when you need different content — an icon-only button, an sr-only label — not merely to get an affordance. Note that as_child hands you the whole button: data-dropdown-menu-target=\"trigger\", data-action=\"dropdown-menu#toggle\", aria-haspopup and aria-expanded are all yours to write. The controller updates aria-expanded at runtime, but only if the attribute is there to begin with.\n\n\n\n \n \n Preview\n \n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n \n \n default\n \n\n \n \n\n \n \n\n \n \n \n \n\n\n\n\n<%= render "components/dropdown_menu/trigger", as_child: true do %>\n <button type="button"\n data-component="button"\n data-variant="ghost"\n data-size="icon"\n data-dropdown-menu-target="trigger"\n data-action="dropdown-menu#toggle"\n aria-haspopup="menu"\n aria-expanded="false">\n <%= icon_for :more_horizontal, class: "size-4" %>\n </button>\n<% end %>\n\nPlacement\n\n\n\n \n \n Preview\n \n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n \n \n default\n \n\n \n \n\n \n \n\n \n \n \n \n\n\n\n\nSince 0.7.1 the menu measures itself when it opens and flips above the trigger if it would otherwise open past the bottom of the viewport. It flips only when the space above genuinely fits — flipping into a gap that is also too small trades one clipped menu for another — and it re-measures from the placement you asked for each time, so a menu that flipped in a short window returns to its default once the window grows.\n\nside: is therefore an initial preference rather than a fixed position. The controller writes the resolved side to data-side on the content element, which is what the CSS positions against. Only the block axis flips; :left and :right are left alone.\n\nThe menu button behaves the same way. If you carry your own collision or flip controller, you can delete it — bin/rails maquina:doctor reports it as app-level-dropdown-flip.\n\nAPI Reference\n\nDropdown Menu\n\n\n \n \n Parameter\n Type\n Default\n Description\n \n \n \n \n css_classes\n String\n \"\"\n Additional CSS classes\n \n \n html_options\n Hash\n {}\n Additional HTML attributes\n \n \n\n\n\nDropdown Menu Trigger\n\n\n \n \n Parameter\n Type\n Default\n Description\n \n \n \n \n variant\n Symbol\n :outline\n Button variant when as_child is false\n \n \n size\n Symbol\n :default\n Button size when as_child is false\n \n \n as_child\n Boolean\n false\n Use custom trigger markup\n \n \n css_classes\n String\n \"\"\n Additional CSS classes\n \n \n html_options\n Hash\n {}\n Additional HTML attributes\n \n \n\n\n\nDropdown Menu Content\n\n\n \n \n Parameter\n Type\n Default\n Description\n \n \n \n \n align\n Symbol\n :start\n :start, :center, :end\n \n \n side\n Symbol\n :bottom\n :top, :bottom, :left, :right — a preference; see Placement below\n \n \n width\n Symbol\n :default\n :default, :sm, :md, :lg\n \n \n css_classes\n String\n \"\"\n Additional CSS classes\n \n \n html_options\n Hash\n {}\n Additional HTML attributes\n \n \n\n\n\nDropdown Menu Item\n\n\n \n \n Parameter\n Type\n Default\n Description\n \n \n \n \n href\n String\n nil\n URL, renders link if provided\n \n \n method\n Symbol\n nil\n HTTP method (:delete, :post, etc.)\n \n \n variant\n Symbol\n :default\n :default or :destructive\n \n \n disabled\n Boolean\n false\n Whether disabled\n \n \n css_classes\n String\n \"\"\n Additional CSS classes\n \n \n html_options\n Hash\n {}\n Additional HTML attributes\n \n \n\n\n\nDropdown Menu Label\n\n\n \n \n Parameter\n Type\n Default\n Description\n \n \n \n \n text\n String\n nil\n Label text\n \n \n content\n String\n nil\n Captured HTML via capture, or use block\n \n \n inset\n Boolean\n false\n Align with icon items\n \n \n css_classes\n String\n \"\"\n Additional CSS classes\n \n \n html_options\n Hash\n {}\n Additional HTML attributes\n \n \n\n\n\nDropdown Menu Separator\n\n\n \n \n Parameter\n Type\n Default\n Description\n \n \n \n \n css_classes\n String\n \"\"\n Additional CSS classes\n \n \n html_options\n Hash\n {}\n Additional HTML attributes\n \n \n\n\n\nDropdown Menu Group\n\n\n \n \n Parameter\n Type\n Default\n Description\n \n \n \n \n css_classes\n String\n \"\"\n Additional CSS classes\n \n \n html_options\n Hash\n {}\n Additional HTML attributes\n \n \n\n\n\nDropdown Menu Shortcut\n\n\n \n \n Parameter\n Type\n Default\n Description\n \n \n \n \n text\n String\n nil\n Shortcut text\n \n \n content\n String\n nil\n Captured HTML via capture, or use block\n \n \n css_classes\n String\n \"\"\n Additional CSS classes\n \n \n html_options\n Hash\n {}\n Additional HTML attributes\n \n \n\n\nBuilder Helper\n\nThe dropdown_menu helper builds the whole menu — trigger, content, items, separators, and shortcuts — without composing partials by hand:\n\n<%= dropdown_menu do |menu| %>\n <% menu.trigger do %>\n <%= icon_for :more_horizontal %>\n <% end %>\n <% menu.content align: :end, width: :md do %>\n <% menu.label "Actions" %>\n <% menu.item "Edit", href: edit_path, icon: :pencil do |item| %>\n <% item.shortcut "⌘E" %>\n <% end %>\n <% menu.separator %>\n <% menu.item "Delete", href: delete_path, method: :delete, variant: :destructive, icon: :trash %>\n <% end %>\n<% end %>\n\nFor data-driven menus, dropdown_menu_simple renders trigger and items from a list:\n\n<%= dropdown_menu_simple "Actions", items: [\n { label: "Edit", href: edit_path, icon: :pencil },\n { label: "Delete", href: delete_path, method: :delete, destructive: true }\n] %>\n\n\n \n \n Builder Method\n Description\n \n \n \n \n menu.trigger(variant:, size:, as_child:, &block)\n Renders the trigger button\n \n \n menu.content(align:, side:, width:, &block)\n Positioned menu container\n \n \n menu.item(label, href:, method:, icon:, variant:, disabled:, &block)\n Menu item; yields an item builder for shortcut(text)\n \n \n menu.label(text, inset:)\n Section heading\n \n \n menu.separator / menu.group(&block)\n Divider / logical grouping" }, { "id": "documentation-components-empty", "title": "Empty", "collection": { "label": "pages", "name": "Posts" }, "categories": "", "tags": "", "url": "/documentation/components/empty/", "content": "Preview\n \n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n \n \n default\n \n\n \n \n\n \n \n\n \n \n \n \n\n\n\n\nUsage\n\n<%= render "components/empty" do %>\n <%= render "components/empty/header" do %>\n <%= render "components/empty/media", icon: :inbox %>\n <%= render "components/empty/title", text: "No messages" %>\n <%= render "components/empty/description", text: "Messages you receive will appear here." %>\n <% end %>\n<% end %>\n\nExamples\n\nWith Action\n\n\n\n \n \n Preview\n \n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n \n \n default\n \n\n \n \n\n \n \n\n \n \n \n \n\n\n\n\n<%= render "components/empty" do %>\n <%= render "components/empty/header" do %>\n <%= render "components/empty/media", icon: :folder %>\n <%= render "components/empty/title", text: "No projects yet" %>\n <%= render "components/empty/description", text: "Get started by creating your first project." %>\n <% end %>\n <%= render "components/empty/content" do %>\n <button data-component="button" data-variant="primary">Create project</button>\n <% end %>\n<% end %>\n\nOutline Variant\n\n\n\n \n \n Preview\n \n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n \n \n default\n \n\n \n \n\n \n \n\n \n \n \n \n\n\n\n\n<%= render "components/empty", variant: :outline do %>\n <%= render "components/empty/header" do %>\n <%= render "components/empty/media", icon: :upload %>\n <%= render "components/empty/title", text: "Drop files here" %>\n <% end %>\n<% end %>\n\nCompact Size\n\n\n\n \n \n Preview\n \n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n \n \n default\n \n\n \n \n\n \n \n\n \n \n \n \n\n\n\n\n<%= render "components/empty", size: :compact do %>\n <%= render "components/empty/header" do %>\n <%= render "components/empty/media", icon: :search %>\n <%= render "components/empty/title", text: "No results found" %>\n <% end %>\n<% end %>\n\nAPI Reference\n\nEmpty\n\n\n \n \n Parameter\n Type\n Default\n Description\n \n \n \n \n variant\n Symbol\n :default\n :default or :outline\n \n \n size\n Symbol\n :default\n :default or :compact\n \n \n css_classes\n String\n \"\"\n Additional CSS classes\n \n \n html_options\n Hash\n {}\n Additional HTML attributes\n \n \n\n\n\nEmpty Header\n\n\n \n \n Parameter\n Type\n Default\n Description\n \n \n \n \n css_classes\n String\n \"\"\n Additional CSS classes\n \n \n html_options\n Hash\n {}\n Additional HTML attributes\n \n \n\n\n\nEmpty Media\n\n\n \n \n Parameter\n Type\n Default\n Description\n \n \n \n \n icon\n Symbol\n nil\n Icon name\n \n \n content\n String\n nil\n Captured HTML via capture, or use block\n \n \n variant\n Symbol\n :icon\n :icon or :avatar\n \n \n css_classes\n String\n \"\"\n Additional CSS classes\n \n \n html_options\n Hash\n {}\n Additional HTML attributes\n \n \n\n\n\nEmpty Title\n\n\n \n \n Parameter\n Type\n Default\n Description\n \n \n \n \n text\n String\n nil\n Title text\n \n \n content\n String\n nil\n Captured HTML via capture, or use block\n \n \n css_classes\n String\n \"\"\n Additional CSS classes\n \n \n html_options\n Hash\n {}\n Additional HTML attributes\n \n \n\n\n\nEmpty Description\n\n\n \n \n Parameter\n Type\n Default\n Description\n \n \n \n \n text\n String\n nil\n Description text\n \n \n content\n String\n nil\n Captured HTML via capture, or use block\n \n \n css_classes\n String\n \"\"\n Additional CSS classes\n \n \n html_options\n Hash\n {}\n Additional HTML attributes\n \n \n\n\n\nEmpty Content\n\n\n \n \n Parameter\n Type\n Default\n Description\n \n \n \n \n css_classes\n String\n \"\"\n Additional CSS classes\n \n \n html_options\n Hash\n {}\n Additional HTML attributes\n \n \n\n\nHelper Methods\n\nThree helpers cover the common empty-state patterns without composing partials:\n\n<%= empty_state title: "No documents", description: "Create your first document.", icon: :folder do %>\n <%= link_to "New document", new_document_path, data: { component: "button", variant: "primary" } %>\n<% end %>\n\n<%= empty_search_state query: params[:q], reset_path: documents_path %>\n\n<%= empty_list_state resource_name: "project", new_path: new_project_path %>\n\n\n \n \n Method\n Description\n \n \n \n \n empty_state(title:, description:, icon:, variant:, size:, &block)\n General empty state; block renders action content\n \n \n empty_search_state(query:, reset_path:, size:)\n No-results state for searches, with optional reset link\n \n \n empty_list_state(resource_name:, new_path:, icon:, size:)\n First-run state for empty collections, with optional create link" }, { "id": "documentation-components-form", "title": "Form", "collection": { "label": "pages", "name": "Posts" }, "categories": "", "tags": "", "url": "/documentation/components/form/", "content": "Preview\n \n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n \n \n default\n \n\n \n \n\n \n \n\n \n \n \n \n\n\n\n\nUsage\n\n<%= form_with model: @user, data: { component: "form" } do |f| %>\n <div data-form-part="group">\n <%= f.label :email, data: { component: "label" } %>\n <%= f.email_field :email, data: { component: "input" }, placeholder: "you@example.com" %>\n </div>\n\n <%= f.submit "Sign in", data: { component: "button", variant: "primary" } %>\n<% end %>\n\nExamples\n\nInput\n\n\n\n \n \n Preview\n \n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n \n \n default\n \n\n \n \n\n \n \n\n \n \n \n \n\n\n\n\n<%= f.text_field :name, data: { component: "input" }, placeholder: "Full name" %>\n<%= f.text_field :name, data: { component: "input", size: "sm" } %>\n<%= f.text_field :name, data: { component: "input", size: "lg" } %>\n\nTextarea\n\n\n\n \n \n Preview\n \n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n \n \n default\n \n\n \n \n\n \n \n\n \n \n \n \n\n\n\n\n<%= f.text_area :bio, data: { component: "textarea" }, rows: 4 %>\n\nSelect\n\n\n\n \n \n Preview\n \n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n \n \n default\n \n\n \n \n\n \n \n\n \n \n \n \n\n\n\n\n<%= f.select :country, options, {}, data: { component: "select" } %>\n\nCheckbox\n\n\n\n \n \n Preview\n \n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n \n \n default\n \n\n \n \n\n \n \n\n \n \n \n \n\n\n\n\n<label class="flex items-center gap-2">\n <%= f.check_box :terms, data: { component: "checkbox" } %>\n <span class="text-sm">Accept terms</span>\n</label>\n\nRadio\n\n\n\n \n \n Preview\n \n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n \n \n default\n \n\n \n \n\n \n \n\n \n \n \n \n\n\n\n\n<label class="flex items-center gap-2">\n <%= f.radio_button :plan, "pro", data: { component: "radio" } %>\n <span class="text-sm">Pro</span>\n</label>\n\nSwitch\n\n\n\n \n \n Preview\n \n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n \n \n default\n \n\n \n \n\n \n \n\n \n \n \n \n\n\n\n\n<label class="flex items-center gap-3">\n <%= f.check_box :notifications, data: { component: "switch" } %>\n <span class="text-sm">Enable notifications</span>\n</label>\n\nButton\n\n\n\n \n \n Preview\n \n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n \n \n default\n \n\n \n \n\n \n \n\n \n \n \n \n\n\n\n\n<button data-component="button" data-variant="primary">Primary</button>\n<button data-component="button" data-variant="secondary">Secondary</button>\n<button data-component="button" data-variant="destructive">Destructive</button>\n<button data-component="button" data-variant="outline">Outline</button>\n<button data-component="button" data-variant="ghost">Ghost</button>\n<button data-component="button" data-variant="link">Link</button>\n\nError States\n\n\n\n \n \n Preview\n \n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n \n \n default\n \n\n \n \n\n \n \n\n \n \n \n \n\n\n\n\nThe error state is driven by aria-invalid on the field. Set it from your model and the border, the focus ring and the message all follow:\n\n<div data-form-part="group">\n <%= f.label :email, data: { component: "label" } %>\n <%= f.email_field :email, data: { component: "input" },\n aria: { invalid: @user.errors[:email].any?,\n describedby: ("email-error" if @user.errors[:email].any?) } %>\n <% if @user.errors[:email].any? %>\n <p data-form-part="error" id="email-error"><%= @user.errors[:email].first %></p>\n <% end %>\n</div>\n\nRails' own .field_with_errors wrapper is styled too, so an app that leans on the default wrapper gets the same treatment without setting anything.\n\nDo not rely on :invalid alone\n\nBefore 0.7.1 the engine also matched :invalid:not(:placeholder-shown), which meant a required field with no placeholder painted the error state on first paint — before focus, before blur, before submit — while telling screen readers nothing, because no aria-invalid accompanied it.\n\nThat is now :user-invalid, which only matches once the reader has actually interacted with the field. Two consequences:\n\n\n A pristine required field renders normally. You no longer need a placeholder to suppress a false error.\n If you render server-side errors and never set aria-invalid, you were relying on the old :invalid match and the border will stop appearing. Set aria-invalid as above.\n\n\nbin/rails maquina:doctor reports the second case as invalid-styling-without-aria.\n\nError text colour\n\nThe message reads --destructive-text, and the invalid border reads --destructive-border. Both default to what they painted before, so the installer's palette needs no change. If your palette is the saturated shadcn kind — --destructive a strong red, --destructive-foreground near-white — add:\n\n:root {\n --destructive-text: var(--destructive);\n --destructive-border: var(--destructive);\n}\n\nSee Theming for why the two palettes differ.\n\nAPI Reference\n\nForm Container\n\n\n \n \n Attribute\n Description\n \n \n \n \n data-component=\"form\"\n Grid layout with gap\n \n \n data-form-part=\"group\"\n Field group container\n \n \n data-form-part=\"description\"\n Help text styling\n \n \n data-form-part=\"error\"\n Error message styling; colour comes from --destructive-text\n \n \n data-form-part=\"actions\"\n Submit area container\n \n \n\n\n\nInput\n\n\n \n \n Attribute\n Values\n Description\n \n \n \n \n data-component\n input\n Text input styling\n \n \n data-size\n sm, lg\n Size variant\n \n \n\n\n\nTextarea\n\n\n \n \n Attribute\n Values\n Description\n \n \n \n \n data-component\n textarea\n Textarea styling\n \n \n\n\n\nSelect\n\n\n \n \n Attribute\n Values\n Description\n \n \n \n \n data-component\n select\n Native select styling\n \n \n\n\n\nCheckbox\n\n\n \n \n Attribute\n Values\n Description\n \n \n \n \n data-component\n checkbox\n Checkbox styling\n \n \n\n\n\nRadio\n\n\n \n \n Attribute\n Values\n Description\n \n \n \n \n data-component\n radio\n Radio button styling\n \n \n\n\n\nSwitch\n\n\n \n \n Attribute\n Values\n Description\n \n \n \n \n data-component\n switch\n Toggle switch styling\n \n \n\n\n\nLabel\n\n\n \n \n Attribute\n Values\n Description\n \n \n \n \n data-component\n label\n Label styling\n \n \n data-required\n (presence)\n Shows required indicator\n \n \n\n\n\nButton\n\n\n \n \n Attribute\n Values\n Description\n \n \n \n \n data-component\n button\n Button styling\n \n \n data-variant\n primary, secondary, destructive, outline, ghost, link\n Visual style\n \n \n data-size\n sm, lg, icon, icon-sm, icon-lg\n Size variant" }, { "id": "documentation-components-header", "title": "Header", "collection": { "label": "pages", "name": "Posts" }, "categories": "", "tags": "", "url": "/documentation/components/header/", "content": "Quick Reference\n\nParameters\n\n\n \n \n Parameter\n Type\n Default\n Description\n \n \n \n \n css_classes\n String\n \"\"\n Additional CSS classes\n \n \n **html_options\n Hash\n {}\n HTML attributes (id:, data:, etc.)\n \n \n\n\nData Attributes\n\nComponent Identifier\n\n\n \n \n Attribute\n Element\n Description\n \n \n \n \n data-component=\"header\"\n <header>\n Main component identifier\n \n \n\n\n\n\nBasic Usage\n\n<%= render \"components/header\" do %>\n <%= render \"components/sidebar/trigger\" %>\n <%= render \"components/separator\", orientation: :vertical %>\n <%= breadcrumbs({\"Dashboard\" => dashboard_path}, @page_title) %>\n<% end %>\n\n\n\n\nExamples\n\nWith Breadcrumbs\n\n<%= render \"components/header\" do %>\n <%= render \"components/sidebar/trigger\" %>\n <%= render \"components/separator\", orientation: :vertical %>\n <%= breadcrumbs(\n {\"Dashboard\" => dashboard_path, \"Users\" => users_path},\n \"John Doe\"\n ) %>\n<% end %>\n\n\nWith Actions\n\n<%= render \"components/header\" do %>\n <%= render \"components/sidebar/trigger\" %>\n <%= render \"components/separator\", orientation: :vertical %>\n <%= breadcrumbs({\"Projects\" => projects_path}, @project.name) %>\n \n <div class=\"ml-auto flex items-center gap-2\">\n <%= link_to \"Edit\", edit_project_path(@project), data: { component: \"button\", variant: \"outline\", size: \"sm\" } %>\n <%= link_to \"Delete\", project_path(@project), data: { component: \"button\", variant: \"destructive\", size: \"sm\" }, method: :delete %>\n </div>\n<% end %>\n\n\nWith Search\n\n<%= render \"components/header\" do %>\n <%= render \"components/sidebar/trigger\" %>\n <%= render \"components/separator\", orientation: :vertical %>\n \n <div class=\"flex-1 max-w-md\">\n <%= form_with url: search_path, method: :get, class: \"relative\" do |f| %>\n <%= icon_for :search, class: \"absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground\" %>\n <%= f.search_field :q, data: { component: \"input\" }, class: \"pl-10 h-8\", placeholder: \"Search...\" %>\n <% end %>\n </div>\n \n <div class=\"ml-auto flex items-center gap-2\">\n <%= render \"components/dropdown_menu\" do %>\n <% end %>\n </div>\n<% end %>\n\n\nSimple Page Title\n\n<%= render \"components/header\" do %>\n <%= render \"components/sidebar/trigger\" %>\n <%= render \"components/separator\", orientation: :vertical %>\n <h1 class=\"text-sm font-medium\">Dashboard</h1>\n<% end %>\n\n\n\n\nReal-World Patterns\n\nStandard App Header\n\n<%= render \"components/header\" do %>\n <%= render \"components/sidebar/trigger\" %>\n <%= render \"components/separator\", orientation: :vertical %>\n \n <%= responsive_breadcrumbs(@breadcrumb_links, @breadcrumb_current) %>\n \n <div class=\"ml-auto flex items-center gap-3\">\n <button type=\"button\" data-component=\"button\" data-variant=\"ghost\" data-size=\"icon-sm\" class=\"relative\">\n <%= icon_for :bell, class: \"size-4\" %>\n <span class=\"absolute -top-1 -right-1 size-4 rounded-full bg-destructive text-destructive-foreground text-xs flex items-center justify-center\">3</span>\n </button>\n \n <%= dropdown_menu do |menu| %>\n <% menu.trigger variant: :ghost, size: :sm do %>\n <%= image_tag current_user.avatar, class: \"size-6 rounded-full\" %>\n <% end %>\n <% menu.content align: :end do %>\n <% menu.label { current_user.name } %>\n <% menu.separator %>\n <% menu.item \"Profile\", href: profile_path, icon: :user %>\n <% menu.item \"Settings\", href: settings_path, icon: :settings %>\n <% menu.separator %>\n <% menu.item \"Logout\", href: logout_path, method: :delete, icon: :log_out %>\n <% end %>\n <% end %>\n </div>\n<% end %>\n\n\nWith Tabs\n\n<%= render \"components/header\" do %>\n <%= render \"components/sidebar/trigger\" %>\n <%= render \"components/separator\", orientation: :vertical %>\n \n <nav class=\"flex items-center gap-1\">\n <%= link_to \"Overview\", project_path(@project), \n class: \"px-3 py-1.5 text-sm rounded-md #{'bg-accent text-accent-foreground' if current_page?(project_path(@project))}\" %>\n <%= link_to \"Tasks\", project_tasks_path(@project),\n class: \"px-3 py-1.5 text-sm rounded-md #{'bg-accent text-accent-foreground' if current_page?(project_tasks_path(@project))}\" %>\n <%= link_to \"Settings\", edit_project_path(@project),\n class: \"px-3 py-1.5 text-sm rounded-md #{'bg-accent text-accent-foreground' if current_page?(edit_project_path(@project))}\" %>\n </nav>\n<% end %>\n\n\n\n\nTheme Variables\n\nvar(--background)\nvar(--border)\n\n\n\n\nCustomization\n\nFixed Height\n\nThe header has a fixed height for consistency with sidebar layouts:\n\n[data-component=\"header\"] {\n @apply h-14;\n}\n\n\nSticky Header\n\n<%= render \"components/header\", css_classes: \"sticky top-0 z-50\" do %>\n<% end %>\n\n\n\n\nAccessibility\n\n\n Uses semantic <header> element\n Works with skip links for keyboard navigation\n Provides consistent landmark for screen readers\n\n\n\n\nFile Structure\n\napp/views/components/\n└── _header.html.erb\n\napp/assets/stylesheets/header.css\ndocs/header.md" }, { "id": "documentation-components", "title": "Components", "collection": { "label": "pages", "name": "Posts" }, "categories": "", "tags": "", "url": "/documentation/components/", "content": "Production-ready UI components for Rails applications. Copy-paste ERB partials styled with Tailwind CSS 4.0 and optional Stimulus controllers.\n\nWhat you get:\n\n 20+ components — From layouts to forms, navigation to feedback\n Zero dependencies — Just Tailwind CSS and optionally Stimulus\n Token-driven theming — Familiar shadcn/ui CSS variables for color, plus tokens for shape, elevation, focus rings and weight\n Rails conventions — ERB partials, data attributes, form helpers\n\n\n\n \n \n\n\n\n Already on 0.5.1? 0.6.0 is a deliberately breaking release: engine CSS moved into @layer components, and radius, elevation, focus rings and weights became tokens. One change affects every existing app and fails silently — the unlayered * rule in your installed theme.css. Run the scanner, then read the guide:\n\n bundle update maquina-components\nbin/rails maquina:doctor\n \n\n → Upgrading to 0.6.0 · Theming\n\n\n\n Latest release: 0.7.1. No API changes, but not purely additive: a required field with no placeholder used to paint the error state from first paint, and the fix means an app that renders server-side errors without setting aria-invalid loses its error border. Field error text gains its own --destructive-text token — one line of theme CSS if your palette is the saturated kind. The dropdown menu and menu button now flip when they hit the bottom of the viewport, and all eighteen leaf partials accept a block. maquina:doctor gained five rules for this release.\n\n → What changes in 0.7.1 · Theming\n\n\n\n\nDemo Application\n\nView Live Demo →\n\nExplore all components in action without installing anything. The demo showcases light/dark themes, color themes, and responsive layouts.\n\nFor local development, clone the components repository:\n\ngit clone https://github.com/maquina-app/maquina_components.git\ncd maquina_components/test/dummy\nbin/rails server\n\n\nVisit http://localhost:3000 to explore the components locally.\n\n\n\nQuick Start\n\n1. Add the Gem\n\n# Gemfile\ngem \"maquina-components\"\n\n\nbundle install\n\n\n2. Run the Install Generator\n\nbin/rails generate maquina_components:install\n\n\nThis adds the engine CSS import, theme variables (shadcn/ui convention), a shape/state token block for radius, elevation, focus rings and weights, and a helper file for icon customization.\n\nRe-running the generator is safe: it is idempotent, appends each block only once, and never rewrites your palette.\n\n3. Start Using Components\n\n<%= render \"components/card\" do %>\n <%= render \"components/card/header\" do %>\n <%= render \"components/card/title\", text: \"Welcome\" %>\n <% end %>\n <%= render \"components/card/content\" do %>\n <p>Your content here</p>\n <% end %>\n<% end %>\n\n\nEvery partial takes its content three ways, and they are interchangeable — containers and leaves alike:\n\n<%= render \"components/card/title\", text: \"Welcome\" %>\n<%= render \"components/card/title\", content: \"Welcome\" %>\n<%= render \"components/card/title\" do %>Welcome<% end %>\n\n\ntext: wins when present, then content:, then the block. Pass a block whenever the content is markup rather than a string. Before 0.7.1 half the leaf partials silently dropped the block, so if you worked around that with capture, you no longer need to.\n\nFor form elements, use data attributes:\n\n<%= form_with model: @user do |f| %>\n <%= f.text_field :email, data: { component: \"input\" } %>\n <%= f.submit \"Save\", data: { component: \"button\", variant: \"primary\" } %>\n<% end %>\n\n\n\n\nAI-Assisted Development\n\nUse the Maquina UI Standards Claude Code plugin to generate views that follow component conventions automatically.\n\nInstead of correcting AI-generated code (“use the card partial, not a div”), the plugin teaches Claude your component patterns:\n\n> Create the users index view with a table showing name, email, and status\n\n\nClaude generates code using your actual components — proper partials, correct data attributes, and consistent patterns.\n\n\n\nAvailable Components\n\nLayout\n\n\n \n \n Component\n Description\n \n \n \n \n Sidebar\n Collapsible navigation with mobile support and keyboard shortcuts\n \n \n Header\n Page header for sidebar layouts with breadcrumbs and actions\n \n \n Drawer\n Slide-out panel with overlay, persistence, and keyboard shortcut\n \n \n\n\nContent\n\n\n \n \n Component\n Description\n \n \n \n \n Card\n Content containers with header, body, and footer sections\n \n \n Alert\n Callouts with 4 variants and icon support\n \n \n Badge\n Status indicators with 7 variants and 3 sizes\n \n \n Table\n Responsive data tables with striped and bordered variants, sticky headers, and a collection helper\n \n \n Empty State\n Placeholder for no-data scenarios with icons and actions\n \n \n Separator\n Horizontal or vertical divider\n \n \n Stats\n Metric cards in a responsive grid\n \n \n\n\nNavigation\n\n\n \n \n Component\n Description\n \n \n \n \n Breadcrumbs\n Navigation with responsive collapsing support\n \n \n Dropdown Menu\n Actions menu triggered by a button with keyboard navigation\n \n \n Pagination\n Navigation for paginated content with Pagy integration\n \n \n\n\nInteractive\n\n\n \n \n Component\n Description\n \n \n \n \n Calendar\n Date selection with single and range modes\n \n \n Combobox\n Searchable dropdown with keyboard navigation and filtering\n \n \n Date Picker\n Popover calendar triggered by a button for date selection\n \n \n Toggle Group\n Single or multiple selection button groups\n \n \n\n\nFeedback\n\n\n \n \n Component\n Description\n \n \n \n \n Toast\n Non-intrusive notifications with auto-dismiss and variants\n \n \n\n\nForms\n\n\n \n \n Component\n Description\n \n \n \n \n Form Components\n Inputs, selects, checkboxes styled with data attributes\n \n \n\n\n\n\nPrerequisites\n\nThe generator requires tailwindcss-rails:\n\nbundle add tailwindcss-rails\nbin/rails tailwindcss:install\n\n\n\n\nStimulus Setup\n\nInteractive components (Sidebar, Dropdown Menu, Toggle Group, Breadcrumbs, Combobox, Toast) require Stimulus. With importmaps:\n\n# config/importmap.rb\npin \"@hotwired/turbo-rails\", to: \"turbo.min.js\"\npin \"@hotwired/stimulus\", to: \"stimulus.min.js\"\npin \"@hotwired/stimulus-loading\", to: \"stimulus-loading.js\"\npin_all_from \"app/javascript/controllers\", under: \"controllers\"\n\n\n// app/javascript/application.js\nimport \"@hotwired/turbo-rails\"\nimport { Application } from \"@hotwired/stimulus\"\nimport { eagerLoadControllersFrom } from \"@hotwired/stimulus-loading\"\n\nconst application = Application.start()\napplication.debug = false\nwindow.Stimulus = application\n\neagerLoadControllersFrom(\"controllers\", application)\n\n\nStatic components (Badge, Card, Alert, Button, form elements) work without JavaScript.\n\nExtending Component Behavior\n\nEvery component merges your data: hash with its own data attributes. Identity keys (component, variant, size) always win, but controller and action concatenate — so you can attach your own Stimulus behavior to any component without losing the built-in one:\n\n<%= render \"components/combobox\", name: \"country\",\n data: { controller: \"analytics\", action: \"change->analytics#track\" } %>\n<%# renders data-controller=\"combobox analytics\" %>\n\n\n\n\nIcons\n\nComponents render icons through the icon_for helper, which falls back to a built-in set of inline SVGs (check, chevrons, calendar, search, mail, trash, and more):\n\n<%= icon_for :check, class: \"size-4\" %>\n<%= icon_for :trash, class: \"size-4\", stroke_width: 1.5 %>\n\n\nTo use your own icon system (Heroicons, Lucide, inline SVG files), override main_icon_svg_for in the generated MaquinaComponentsHelper — icon_for consults it first and only falls back to the built-ins when it returns nil:\n\n# app/helpers/maquina_components_helper.rb\ndef main_icon_svg_for(name)\n lucide_icon(name)\nend\n\n\nOr return the SVG yourself, a name at a time:\n\nmodule MaquinaComponentsHelper\n def main_icon_svg_for(name)\n case name\n when :home\n <<~SVG\n <svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n <path d=\"M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8\"/>\n <path d=\"M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z\"/>\n </svg>\n SVG\n end\n end\nend\n\n\nIcons are sourced from Lucide. Copy SVG code directly from their website.\n\nWhat the override does and does not reach\n\nmain_icon_svg_for backs the public icon_for helper — every icon you render, plus the component parameters that take an icon name (alert, sidebar menu items, empty states, breadcrumb separators).\n\nIt deliberately does not reach the icons an engine component renders for itself: a dropdown trigger’s chevron, the toast close button, the calendar’s arrows. Those go through an internal builtin_icon_for that only ever reads the engine’s own set, so a component looks the same in every app regardless of how you have configured icons — and so a partial override cannot leave a control without its affordance.\n\nThe practical consequence: if an engine component’s own icon looks wrong or missing, defining that name in main_icon_svg_for will not change it. That is a bug in the engine, not something to fix in your app — please report it.\n\nCatching typos: strict_icons\n\nAn unknown icon name renders nothing at all, which is invisible in review and in production. MaquinaComponents.strict_icons raises UnknownIconError instead. It is on by default in development and test and off in production, so a typo fails loudly while you work and can never take a page down for a user.\n\n# config/initializers/maquina_components.rb\nMaquinaComponents.strict_icons = false # opt out; unknown names render nothing\n\n\nThis covers both helpers. If it raises for a name you never wrote yourself, an engine component asked for an icon the engine does not ship — the message says so, and says that a main_icon_svg_for entry will not help.\n\n\n\nTheme Variables\n\nColors are CSS variables following the shadcn/ui theming convention.\n\nEach one is defined twice, which Tailwind CSS v4 requires: in :root for the value, and in @theme so it also becomes a utility (bg-primary, text-muted-foreground).\n\n:root {\n --primary: oklch(0.488 0.243 264.376);\n --primary-foreground: oklch(0.985 0 0);\n}\n\n@theme {\n --color-primary: var(--primary);\n --color-primary-foreground: var(--primary-foreground);\n}\n\n\nEdit the values in :root to match your brand — the generator installs neutral grays. Adding a semantic color works the same way: declare it in :root, then mirror it in @theme if you want the utility.\n\nEverything that is not a color — shape, focus rings, elevation, weights, control marks — is a token too, and a theme changes values, not selectors. See Theming for the full token table, ready-made themes, and how to pin a single component.\n\nGenerator Options\n\n# Skip theme variables (if you already have them)\nbin/rails generate maquina_components:install --skip-theme\n\n# Skip helper creation\nbin/rails generate maquina_components:install --skip-helper\n\n# Skip both\nbin/rails generate maquina_components:install --skip-theme --skip-helper\n\n\n\n\nFile Structure After Setup\n\napp/\n├── assets/tailwind/\n│ └── application.css # Theme + engine import\n├── helpers/\n│ └── maquina_components_helper.rb # Icon override\n├── javascript/\n│ └── application.js # Stimulus init\n└── views/layouts/\n └── application.html.erb # Layout with components\n\n\n\n\nTroubleshooting\n\nGenerator Issues\n\n“tailwindcss-rails doesn’t appear to be installed”\n\nInstall it first:\n\nbundle add tailwindcss-rails\nbin/rails tailwindcss:install\n\n\nRuntime Issues\n\nSidebar trigger not working\n\n\n Ensure Stimulus is initialized\n Verify provider wraps both sidebar and content\n Check browser console for errors\n\n\nStyles not applying\n\n\n Verify engine CSS is imported after @import \"tailwindcss\";\n Check that @theme block exists with color bindings\n Restart dev server after CSS changes\n\n\nDark mode not working\n\n\n Add .dark class to <html> element\n Ensure .dark { } block has variable overrides\n\n\nIcons not rendering\n\n\n Check the icon name matches your main_icon_svg_for cases\n Verify the helper is included in ApplicationHelper\n Turn on strict_icons in development so an unknown name raises instead of rendering nothing\n If the missing icon belongs to a component rather than to your own markup (a trigger’s chevron, a close button), main_icon_svg_for cannot fix it — see Icons\n\n\n\n\nNext Steps\n\n\n \n \n Sidebar\n \n \n Build your application layout with collapsible navigation.\n \n \n\n \n \n Card\n \n \n Display content in containers with header, body, and footer.\n \n \n\n \n \n Form Components\n \n \n Style inputs, selects, and buttons with data attributes.\n \n \n\n \n \n AI-Assisted Development\n \n \n Use Claude Code to generate views with component conventions." }, { "id": "documentation-components-pagination", "title": "Pagination", "collection": { "label": "pages", "name": "Posts" }, "categories": "", "tags": "", "url": "/documentation/components/pagination/", "content": "Preview\n \n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n \n \n default\n \n\n \n \n\n \n \n\n \n \n \n \n\n\n\n\nUsage\n\n<%= render "components/pagination" do %>\n <%= render "components/pagination/content" do %>\n <%= render "components/pagination/item" do %>\n <%= render "components/pagination/previous", href: "/page/1" %>\n <% end %>\n <%= render "components/pagination/item" do %>\n <%= render "components/pagination/link", href: "/page/1" do %>1<% end %>\n <% end %>\n <%= render "components/pagination/item" do %>\n <%= render "components/pagination/link", href: "/page/2", active: true do %>2<% end %>\n <% end %>\n <%= render "components/pagination/item" do %>\n <%= render "components/pagination/link", href: "/page/3" do %>3<% end %>\n <% end %>\n <%= render "components/pagination/item" do %>\n <%= render "components/pagination/ellipsis" %>\n <% end %>\n <%= render "components/pagination/item" do %>\n <%= render "components/pagination/next", href: "/page/3" %>\n <% end %>\n <% end %>\n<% end %>\n\n\nPagy Integration\n\nFor Pagy-backed collections, the pagination_nav helper renders the full pagination from a Pagy object, with Turbo-aware links:\n\n<%= pagination_nav(@pagy, :users_path) %>\n\n<%# Preserve query params and target a Turbo Frame %>\n<%= pagination_nav(@pagy, :search_users_path,\n params: { q: params[:q] },\n turbo: { action: :replace, frame: "users" }) %>\n\npagination_simple renders the same navigation without page-number labels. Both return nothing when there is a single page.\n\n\n \n \n Parameter\n Type\n Default\n Description\n \n \n \n \n pagy\n Pagy\n required\n The Pagy pagination object\n \n \n route_helper\n Symbol\n required\n Route helper used to build page links\n \n \n params\n Hash\n {}\n Extra query params preserved across pages\n \n \n turbo\n Hash\n { action: :replace }\n Turbo data attributes for the links, e.g. frame:\n \n \n show_labels\n Boolean\n true\n Show Previous/Next text labels (pagination_nav only)\n \n \n css_classes\n String\n \"\"\n Additional CSS classes\n \n \n html_options\n Hash\n {}\n Additional HTML attributes\n \n \n\n\nAPI Reference\n\nPagination\n\n\n \n \n Parameter\n Type\n Default\n Description\n \n \n \n \n css_classes\n String\n \"\"\n Additional CSS classes\n \n \n html_options\n Hash\n {}\n Additional HTML attributes\n \n \n\n\n\nPagination Content\n\n\n \n \n Parameter\n Type\n Default\n Description\n \n \n \n \n css_classes\n String\n \"\"\n Additional CSS classes\n \n \n html_options\n Hash\n {}\n Additional HTML attributes\n \n \n\n\n\nPagination Item\n\n\n \n \n Parameter\n Type\n Default\n Description\n \n \n \n \n css_classes\n String\n \"\"\n Additional CSS classes\n \n \n html_options\n Hash\n {}\n Additional HTML attributes\n \n \n\n\n\nPagination Link\n\n\n \n \n Parameter\n Type\n Default\n Description\n \n \n \n \n href\n String\n required\n URL for the page\n \n \n active\n Boolean\n false\n Whether current page\n \n \n disabled\n Boolean\n false\n Whether disabled\n \n \n css_classes\n String\n \"\"\n Additional CSS classes\n \n \n html_options\n Hash\n {}\n Additional HTML attributes\n \n \n\n\n\nPagination Previous\n\n\n \n \n Parameter\n Type\n Default\n Description\n \n \n \n \n href\n String\n nil\n URL for previous page\n \n \n label\n String\n \"Previous\"\n Button label\n \n \n disabled\n Boolean\n false\n Whether disabled\n \n \n show_label\n Boolean\n true\n Show text label\n \n \n css_classes\n String\n \"\"\n Additional CSS classes\n \n \n html_options\n Hash\n {}\n Additional HTML attributes\n \n \n\n\n\nPagination Next\n\n\n \n \n Parameter\n Type\n Default\n Description\n \n \n \n \n href\n String\n nil\n URL for next page\n \n \n label\n String\n \"Next\"\n Button label\n \n \n disabled\n Boolean\n false\n Whether disabled\n \n \n show_label\n Boolean\n true\n Show text label\n \n \n css_classes\n String\n \"\"\n Additional CSS classes\n \n \n html_options\n Hash\n {}\n Additional HTML attributes\n \n \n\n\n\nPagination Ellipsis\n\n\n \n \n Parameter\n Type\n Default\n Description\n \n \n \n \n css_classes\n String\n \"\"\n Additional CSS classes\n \n \n html_options\n Hash\n {}\n Additional HTML attributes" }, { "id": "documentation-components-separator", "title": "Separator", "collection": { "label": "pages", "name": "Posts" }, "categories": "", "tags": "", "url": "/documentation/components/separator/", "content": "Preview\n \n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n \n \n default\n \n\n \n \n\n \n \n\n \n \n \n \n\n\n\n\nUsage\n\n<%= render "components/separator" %>\n\nExamples\n\nVertical\n\nUse inside a flex row — for example between header actions, as the Header component does.\n\n\n\n \n \n Preview\n \n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n \n \n default\n \n\n \n \n\n \n \n\n \n \n \n \n\n\n\n\n<div class="flex h-8 items-center">\n <span>Docs</span>\n <%= render "components/separator", orientation: :vertical %>\n <span>Source</span>\n</div>\n\nAPI Reference\n\nSeparator\n\n\n \n \n Parameter\n Type\n Default\n Description\n \n \n \n \n orientation\n Symbol\n :horizontal\n :horizontal or :vertical\n \n \n css_classes\n String\n \"\"\n Additional CSS classes\n \n \n html_options\n Hash\n {}\n Additional HTML attributes, including data" }, { "id": "documentation-components-sidebar", "title": "Sidebar", "collection": { "label": "pages", "name": "Posts" }, "categories": "", "tags": "", "url": "/documentation/components/sidebar/", "content": "Preview\n \n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n \n \n default\n \n\n \n \n\n \n \n\n \n \n \n \n\n\n\n\nUsage\n\n<%= render "components/sidebar/provider", default_open: sidebar_open? do %>\n <%= render "components/sidebar", state: sidebar_state do %>\n <%= render "components/sidebar/header" do %>\n <%# Logo/branding %>\n <% end %>\n\n <%= render "components/sidebar/content" do %>\n <%= render "components/sidebar/group", title: "Navigation" do %>\n <%= render "components/sidebar/menu" do %>\n <%= render "components/sidebar/menu_item" do %>\n <%= render "components/sidebar/menu_button",\n title: "Dashboard",\n icon_name: :home,\n url: root_path,\n active: current_page?(root_path) %>\n <% end %>\n <% end %>\n <% end %>\n <% end %>\n\n <%= render "components/sidebar/footer" do %>\n <%# User menu %>\n <% end %>\n <% end %>\n\n <%= render "components/sidebar/inset" do %>\n <%= render "components/header" do %>\n <%= render "components/sidebar/trigger" %>\n <% end %>\n <%= yield %>\n <% end %>\n<% end %>\n\nExamples\n\nMenu Button\n\n<%= render "components/sidebar/menu_button",\n title: "Dashboard",\n icon_name: :home,\n url: root_path,\n active: true %>\n\nMenu Link (Avatar Style)\n\n<%= render "components/sidebar/menu_link",\n url: profile_path,\n text_icon: "A",\n title: "ACME Corp",\n subtitle: "Workspace" %>\n\nMenu Badge and Menu Action\n\nBoth pin to the right edge of the menu item, so they must be rendered inside menu_item, as siblings of the menu_button (or menu_link). That nesting is load-bearing: the item is the positioning context, and a menu item containing an action automatically reserves right-hand padding on its button so the icon never sits on top of the label. The demo above shows both — the 24 count on Inbox and the options control on Calendar.\n\n<%= render "components/sidebar/menu_item" do %>\n <%= render "components/sidebar/menu_button",\n title: "Inbox", icon_name: :inbox, url: inbox_path %>\n <%= render "components/sidebar/menu_badge", text: "24" %>\n <%= render "components/sidebar/menu_action",\n label: "Inbox options",\n icon_name: :ellipsis,\n show_on_hover: true %>\n<% end %>\n\nmenu_action renders a <button type="button"> by default and an <a> when you pass url:. It is icon-only, so label: is required — it becomes both the aria-label and screen-reader text. show_on_hover: true keeps the action invisible until the item is hovered or focused.\n\nGroup Action\n\n<%= render "components/sidebar/group", title: "Projects" do %>\n <%= render "components/sidebar/group_action",\n label: "Add project", url: new_project_path %>\n\n <%= render "components/sidebar/menu" do %>\n <%# ... %>\n <% end %>\n<% end %>\n\nSeparator\n\n<%= render "components/sidebar/separator" %>\n\nRenders the separator primitive, so it keeps the primitive's 1px track while the sidebar part re-spaces it and swaps in the sidebar's own border token.\n\nAPI Reference\n\nProvider\n\n\n \n \n Parameter\n Type\n Default\n Description\n \n \n \n \n id\n String\n \"sidebar-provider\"\n Element ID for stable morph matching\n \n \n default_open\n Boolean\n true\n Initial open state\n \n \n variant\n Symbol\n :inset\n Visual variant\n \n \n cookie_name\n String\n \"sidebar_state\"\n Cookie for persistence\n \n \n keyboard_shortcut\n String\n \"b\"\n Toggle shortcut (Cmd/Ctrl+key)\n \n \n css_classes\n String\n \"\"\n Additional CSS classes\n \n \n html_options\n Hash\n {}\n Additional HTML attributes\n \n \n\n\n\nSidebar\n\n\n \n \n Parameter\n Type\n Default\n Description\n \n \n \n \n id\n String\n auto\n Element ID\n \n \n state\n Symbol\n :collapsed\n :expanded or :collapsed\n \n \n collapsible\n Symbol\n :offcanvas\n :offcanvas, :icon, or :none\n \n \n variant\n Symbol\n :inset\n :sidebar, :floating, or :inset\n \n \n side\n Symbol\n :left\n :left or :right\n \n \n css_classes\n String\n \"\"\n Additional CSS classes\n \n \n html_options\n Hash\n {}\n Additional HTML attributes\n \n \n\n\n\nMenu Button\n\n\n \n \n Parameter\n Type\n Default\n Description\n \n \n \n \n title\n String\n required\n Button text\n \n \n url\n String\n required\n Link URL\n \n \n icon_name\n Symbol\n nil\n Icon name\n \n \n size\n Symbol\n :default\n :default, :sm, or :lg\n \n \n active\n Boolean\n false\n Whether active\n \n \n css_classes\n String\n \"\"\n Additional CSS classes\n \n \n html_options\n Hash\n {}\n Additional HTML attributes\n \n \n\n\n\nMenu Link\n\n\n \n \n Parameter\n Type\n Default\n Description\n \n \n \n \n url\n String\n required\n Link URL\n \n \n title\n String\n required\n Primary text\n \n \n subtitle\n String\n nil\n Secondary text\n \n \n text_icon\n String\n nil\n Text for avatar\n \n \n icon\n String\n nil\n Image URL for avatar\n \n \n active\n Boolean\n false\n Whether active\n \n \n css_classes\n String\n \"\"\n Additional CSS classes\n \n \n html_options\n Hash\n {}\n Additional HTML attributes\n \n \n\n\n\nTrigger\n\n\n \n \n Parameter\n Type\n Default\n Description\n \n \n \n \n icon_name\n Symbol\n :left_panel\n Icon name for toggle button\n \n \n css_classes\n String\n \"\"\n Additional CSS classes\n \n \n html_options\n Hash\n {}\n Additional HTML attributes\n \n \n\n\n\nOther Parts\n\n\n \n \n Partial\n Description\n \n \n \n \n sidebar/header\n Top section for logo/branding\n \n \n sidebar/content\n Scrollable middle section\n \n \n sidebar/footer\n Bottom section for user menu\n \n \n sidebar/group\n Groups menu items with optional title\n \n \n sidebar/menu\n List container for menu items\n \n \n sidebar/menu_item\n Individual menu item wrapper\n \n \n sidebar/menu_badge\n Count or short label pinned inside a menu item. text: / content: / block\n \n \n sidebar/menu_action\n Icon control pinned right inside a menu item. label: (required), url:, icon_name:, show_on_hover:\n \n \n sidebar/group_action\n Icon control pinned to the right of a group label. label: (required), url:, icon_name:\n \n \n sidebar/separator\n Divider between groups. orientation: (default :horizontal)\n \n \n sidebar/trigger\n Toggle button for sidebar\n \n \n sidebar/inset\n Main content area wrapper\n \n \n\n\n\nHelper Methods\n\n\n \n \n Method\n Description\n \n \n \n \n sidebar_state(cookie_name)\n Returns :expanded or :collapsed\n \n \n sidebar_open?(cookie_name)\n Returns true if expanded\n \n \n sidebar_closed?(cookie_name)\n Returns true if collapsed\n \n \n\n\n\nAccessibility\n\n\n A collapsed off-canvas sidebar is out of the tab order. When it is parked off-screen, its container carries inert, so keyboard focus skips it entirely rather than walking through a screenful of destinations no pointer can reach. It is applied server-side as well as by the controller, so the invariant holds before Stimulus connects. A collapsible: :icon sidebar is a visible rail and stays reachable; an open sidebar obviously does too.\n Below 768px the sidebar reserves no layout. The container is a fixed overlay with a backdrop at that width, so the gap that normally holds space for it collapses to zero — structurally, in CSS, whatever the state cookie says and before any JavaScript has run. A phone load carrying an expanded cookie gets a full-width content column in the first painted frame, with no settle.\n Sidebar items expose aria-current=\"page\" when active, and the trigger keeps aria-expanded and aria-controls in sync with the sidebar it drives.\n\n\nTurbo Drive\n\nThe sidebar controller integrates with Turbo Drive to maintain correct state across navigations:\n\n\n Cache teardown: On mobile, the sidebar closes and the backdrop is hidden before Turbo caches the page. Pressing back never shows a stale open sidebar or scroll-locked body.\n Morph awareness: When using turbo_refresh_method_tag :morph, the sidebar re-reads its cookie to preserve the desktop toggle state and forces closed on mobile after a morph refresh.\n Desktop persistence: The sidebar state is stored in a cookie, so it survives full page loads and Turbo navigations without extra configuration.\n\n\nStable IDs\n\nThe sidebar generates deterministic IDs based on its side: parameter (sidebar-left, sidebar-right) instead of random IDs. This allows idiomorph to match old and new elements across morph renders, preventing the sidebar from being destroyed and recreated.\n\nThe provider div also receives a stable ID (sidebar-provider) for the same reason.\n\nIf you render multiple sidebars on the same side, pass explicit id: parameters to avoid collisions:\n\n<%= render "components/sidebar/provider", id: "sidebar-main" do %>\n <%= render "components/sidebar", id: "sidebar-nav", side: :left do %>\n ...\n <% end %>\n<% end %>\n\nMorph Compatibility\n\nDuring a Turbo morph, the server-rendered data-sidebar-open-value may carry a stale value (e.g., from a broadcast where the server has no access to the browser cookie). The controller treats the browser cookie as the source of truth:\n\n\n Before morph updates attributes, the controller sets an internal guard flag.\n When idiomorph overwrites data-sidebar-open-value, the Stimulus value callback is skipped — preventing the stale server value from overwriting the cookie.\n After morph completes, the controller reads the cookie, reasserts the correct state, and removes the sidebar-loading class that morph re-adds from server HTML.\n\n\nTurbo Frames\n\nThe sidebar works inside Turbo Frames because stable IDs enable clean Stimulus disconnect/reconnect cycles. On reconnection, initialize() re-reads the cookie, so the sidebar always reflects the latest client-side state." }, { "id": "documentation-components-stats", "title": "Stats", "collection": { "label": "pages", "name": "Posts" }, "categories": "", "tags": "", "url": "/documentation/components/stats/", "content": "Preview\n \n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n \n \n default\n \n\n \n \n\n \n \n\n \n \n \n \n\n\n\n\nUsage\n\n<%= render "components/stats/stats_grid", columns: 4, cards: [\n { title: "Total Revenue", value: "$1,250.00", icon: :dollar, subtitle: "Trending up this month" },\n { title: "New Customers", value: "1,234", icon: :users },\n { title: "Active Accounts", value: "45,678", icon: :check_circle },\n { title: "Growth Rate", value: "4.5%", icon: :chart_bar }\n] %>\n\nExamples\n\nSingle Card\n\nCards render standalone too. Use value_classes and icon_classes for color accents — a utility class always wins over the theme default.\n\n\n\n \n \n Preview\n \n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n \n \n default\n \n\n \n \n\n \n \n\n \n \n \n \n\n\n\n\n<%= render "components/stats/stats_card",\n title: "Open Tickets",\n value: "12",\n icon: :circle_alert,\n icon_classes: "text-amber-500",\n subtitle: "3 urgent" %>\n\nWith Action\n\n\n\n \n \n Preview\n \n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n \n \n default\n \n\n \n \n\n \n \n\n \n \n \n \n\n\n\n\n<%= render "components/stats/stats_grid",\n columns: 3,\n cards: cards,\n action: link_to("View report", reports_path, data: { component: "button", variant: "outline" }),\n action_position: :end %>\n\nAPI Reference\n\nStats Grid\n\n\n \n \n Parameter\n Type\n Default\n Description\n \n \n \n \n cards\n Array\n []\n Hashes of stats_card parameters\n \n \n columns\n Integer\n 3\n Grid columns from the sm breakpoint up, 1-6\n \n \n action\n String\n nil\n Captured HTML rendered beside the grid\n \n \n action_position\n Symbol\n :end\n :start or :end\n \n \n css_classes\n String\n \"\"\n Additional CSS classes\n \n \n html_options\n Hash\n {}\n Additional HTML attributes, including data\n \n \n\n\nStats Card\n\n\n \n \n Parameter\n Type\n Default\n Description\n \n \n \n \n title\n String\n required\n Metric label\n \n \n value\n String\n required\n Metric value\n \n \n icon\n Symbol\n nil\n Built-in icon name; custom HTML is also accepted\n \n \n subtitle\n String\n nil\n Secondary line under the value\n \n \n icon_classes\n String\n \"\"\n Classes for the icon area, e.g. a color utility\n \n \n value_classes\n String\n \"\"\n Classes for the value, e.g. a color utility\n \n \n css_classes\n String\n \"\"\n Additional CSS classes\n \n \n html_options\n Hash\n {}\n Additional HTML attributes, including data" }, { "id": "documentation-components-table", "title": "Table", "collection": { "label": "pages", "name": "Posts" }, "categories": "", "tags": "", "url": "/documentation/components/table/", "content": "Preview\n \n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n \n \n default\n \n\n \n \n\n \n \n\n \n \n \n \n\n\n\n\nUsage\n\n<%= render "components/table" do %>\n <%= render "components/table/header" do %>\n <%= render "components/table/row" do %>\n <%= render "components/table/head" do %>Name<% end %>\n <%= render "components/table/head" do %>Email<% end %>\n <%= render "components/table/head", css_classes: "text-right" do %>Amount<% end %>\n <% end %>\n <% end %>\n\n <%= render "components/table/body" do %>\n <% @users.each do |user| %>\n <%= render "components/table/row" do %>\n <%= render "components/table/cell" do %><%= user.name %><% end %>\n <%= render "components/table/cell" do %><%= user.email %><% end %>\n <%= render "components/table/cell", css_classes: "text-right" do %><%= user.amount %><% end %>\n <% end %>\n <% end %>\n <% end %>\n<% end %>\n\nExamples\n\nWith Footer\n\n<%= render "components/table" do %>\n <%= render "components/table/header" do %>\n <%# ... %>\n <% end %>\n <%= render "components/table/body" do %>\n <%# ... %>\n <% end %>\n <%= render "components/table/footer" do %>\n <%= render "components/table/row" do %>\n <%= render "components/table/cell", colspan: 2 do %>Total<% end %>\n <%= render "components/table/cell", css_classes: "text-right" do %>$750.00<% end %>\n <% end %>\n <% end %>\n<% end %>\n\nSelected Row\n\n<%= render "components/table/row", selected: true do %>\n <%= render "components/table/cell" do %>Selected item<% end %>\n<% end %>\n\nBordered Variant\n\nDraws a border around the scroll container.\n\n\n\n \n \n Preview\n \n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n \n \n default\n \n\n \n \n\n \n \n\n \n \n \n \n\n\n\n\n<%= render "components/table", variant: :bordered do %>\n <%# ... %>\n<% end %>\n\nStriped Variant\n\nAlternates row backgrounds on the table itself.\n\n\n\n \n \n Preview\n \n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n \n \n default\n \n\n \n \n\n \n \n\n \n \n \n \n\n\n\n\n<%= render "components/table", table_variant: :striped do %>\n <%# ... %>\n<% end %>\n\nSimple Table Helper\n\nFor collection-driven tables, the simple_table helper renders the whole structure from a column definition. Keys can be attribute names, hash keys, or procs.\n\n<%= simple_table @invoices, caption: "Recent invoices", columns: [\n { key: :number, label: "Invoice" },\n { key: :customer, label: "Customer" },\n { key: ->(i) { i.amount.format }, label: "Amount", align: :right }\n], row_id: :id, table_variant: :striped %>\n\nAPI Reference\n\nEvery table partial passes unknown keywords through as HTML attributes, so standard table attributes like colspan and rowspan work directly on cells, and id, aria, or data attributes work on any part.\n\nTable\n\nThe table renders two elements: a scrollable container div and the table element inside it. The variant parameter styles the container (that is why :bordered lives there), while table_variant styles the table element itself.\n\n\n \n \n Parameter\n Type\n Default\n Description\n \n \n \n \n container\n Boolean\n true\n Wrap in scrollable container\n \n \n variant\n Symbol\n nil\n Container variant, :bordered draws a border around the scroll container\n \n \n table_variant\n Symbol\n nil\n Table variant, :striped alternates row backgrounds\n \n \n css_classes\n String\n \"\"\n Additional CSS classes\n \n \n html_options\n Hash\n {}\n Additional HTML attributes\n \n \n\n\n\nTable Header\n\n\n \n \n Parameter\n Type\n Default\n Description\n \n \n \n \n sticky\n Boolean\n false\n Sticky header on scroll\n \n \n css_classes\n String\n \"\"\n Additional CSS classes\n \n \n html_options\n Hash\n {}\n Additional HTML attributes\n \n \n\n\n\nTable Row\n\n\n \n \n Parameter\n Type\n Default\n Description\n \n \n \n \n selected\n Boolean\n false\n Highlight as selected\n \n \n css_classes\n String\n \"\"\n Additional CSS classes\n \n \n html_options\n Hash\n {}\n Additional HTML attributes\n \n \n\n\n\nTable Head\n\n\n \n \n Parameter\n Type\n Default\n Description\n \n \n \n \n text\n String\n nil\n Heading text\n \n \n content\n String\n nil\n Captured HTML via capture, or use block\n \n \n scope\n String\n \"col\"\n Scope attribute for accessibility\n \n \n css_classes\n String\n \"\"\n Additional CSS classes\n \n \n html_options\n Hash\n {}\n Additional HTML attributes\n \n \n\n\n\nTable Cell\n\n\n \n \n Parameter\n Type\n Default\n Description\n \n \n \n \n text\n String\n nil\n Cell text\n \n \n content\n String\n nil\n Captured HTML via capture, or use block\n \n \n css_classes\n String\n \"\"\n Additional CSS classes\n \n \n html_options\n Hash\n {}\n Additional HTML attributes, e.g. colspan, rowspan\n \n \n\n\n\nsimple_table Helper\n\n\n \n \n Parameter\n Type\n Default\n Description\n \n \n \n \n collection\n Enumerable\n required\n Objects or hashes to render\n \n \n columns\n Array\n required\n Hashes with key (attribute, hash key, or proc), label, and optional align (:center, :right; left by default)\n \n \n caption\n String\n nil\n Table caption\n \n \n variant\n Symbol\n nil\n Container variant, :bordered\n \n \n table_variant\n Symbol\n nil\n Table variant, :striped\n \n \n empty_message\n String\n \"No data available\"\n Shown when the collection is empty\n \n \n row_id\n Symbol\n nil\n Method used to build each row id, row-{value}\n \n \n html_options\n Hash\n {}\n Additional HTML attributes for the table\n \n \n\n\n\nTable Body / Footer / Caption\n\nCaption also accepts text and content like cell and head.\n\n\n \n \n Parameter\n Type\n Default\n Description\n \n \n \n \n css_classes\n String\n \"\"\n Additional CSS classes\n \n \n html_options\n Hash\n {}\n Additional HTML attributes" }, { "id": "documentation-components-theming", "title": "Theming", "collection": { "label": "pages", "name": "Posts" }, "categories": "", "tags": "", "url": "/documentation/components/theming/", "content": "Reshape every component by declaring token values, not override CSS.\n\nColors have always been CSS variables. As of 0.6.0 so are shape, focus rings, elevation and weight — which is the whole of what used to require override CSS.\n\nThe contract: a theme changes values, not selectors. If a theme needs a selector, either you are changing one component’s shape on purpose, or the token layer is missing a token — open an issue.\n\nUpgrading from 0.5.1? Read Upgrading first — it leads with a one-line fix every existing app needs.\n\n\n\nRole Tokens\n\nTokens are named for the role a value plays, not for a size, so controls and surfaces can be shaped independently.\n\n\n \n \n Token\n Default\n Applies to\n \n \n \n \n --control-radius\n 0.375rem\n Buttons, inputs, selects, textareas, badges, menu items, pagination links, calendar days, sidebar items\n \n \n --surface-radius\n 0.5rem\n Cards, alerts, popovers, toasts, tables, stats, empty, calendar, drawer, the sidebar inset\n \n \n --mark-radius\n 4px\n The checkbox box\n \n \n --pill-radius\n calc(infinity * 1px)\n Radio, switch track\n \n \n --focus-ring-width\n 3px\n Every focus ring\n \n \n --focus-ring-offset\n 0px\n Every focus ring\n \n \n --focus-ring-style\n solid\n Every focus ring\n \n \n --focus-ring-color\n see below\n Every focus ring; invalid fields and destructive buttons override it with the destructive tint\n \n \n --elevation-control\n shadow-xs\n Inputs, selects, textareas, checkbox, radio\n \n \n --elevation-raised\n shadow-sm\n Cards, stats cards, floating sidebar, every filled button\n \n \n --elevation-overlay\n shadow-md\n Dropdown and combobox popovers, the date-picker popover, toasts, the drawer panel\n \n \n --elevation-none\n none\n Ghost and link buttons, the inset sidebar\n \n \n --label-weight\n 500\n Labels, buttons\n \n \n --value-weight\n 700\n Stat values\n \n \n --control-fill\n transparent\n Field background; re-set under .dark\n \n \n --destructive-text\n var(--destructive-foreground)\n Field error text ([data-form-part=\"error\"])\n \n \n --destructive-border\n var(--destructive)\n The border on an invalid field\n \n \n\n\n--x is a fill; --x-foreground is the text on that fill\n\nEvery -foreground token names the colour that sits on its pair, never text on the page. [data-form-part=\"error\"] is the one place that distinction bites: a field error is body text on a card, so painting it with --destructive-foreground is only correct if your palette happens to define that token as a readable-on-page red.\n\nBoth conventions are in the wild, and they are inverses of each other:\n\n\n \n \n Convention\n --destructive\n --destructive-foreground\n \n \n \n \n Tinted — what bin/rails g maquina_components:install writes, matching --success / --warning\n pale tint\n dark readable red\n \n \n Saturated — shadcn-style\n saturated red\n near-white\n \n \n\n\nSo error text routes through --destructive-text, which defaults to --destructive-foreground — correct under the tinted palette, and one line to fix under a saturated one:\n\n:root {\n --destructive-text: var(--destructive);\n --destructive-border: var(--destructive);\n}\n\n\nrake maquina:doctor measures your own tokens against your --card and reports destructive-error-invisible if the error text cannot be read.\n\n--focus-ring-color has no single default\n\nThe other three focus tokens are declared once in the engine’s @theme block. --focus-ring-color is declared nowhere: each rule supplies its own default as the var() fallback, because the right resting colour differs by family.\n\n\n \n \n Family\n Default when you do not set the token\n \n \n \n \n Buttons, cards, badges, toasts, drawer, pagination, calendar, toggle group, date picker\n var(--ring)\n \n \n Everything inside the sidebar, and the menu button\n var(--sidebar-ring, var(--ring))\n \n \n Form fields — input, textarea, select, checkbox, radio\n color-mix(in oklch, var(--ring) 50%, transparent)\n \n \n\n\nSetting --focus-ring-color once at :root overrides all three at the same time, which is usually what you want — a declared token means no fallback ever fires. Set it in a narrower scope to keep the families apart.\n\nTwo states deliberately outrank a :root override, because a state must win: an aria-invalid field (and anything inside .field_with_errors) and a data-variant=\"destructive\" button declare --focus-ring-color on the element itself. An element’s own custom property beats an inherited one, so those rings stay on the destructive tint whatever :root says.\n\nThe invalid field’s border is separate from its ring and reads --destructive-border; its error message reads --destructive-text. Since 0.7.1 the invalid state is keyed on aria-invalid and :user-invalid, so a pristine required field no longer paints it.\n\nNever transition outline-color\n\nIf you write your own component against these tokens, keep outline-color out of its transition — and that means not using Tailwind’s transition-colors, which includes outline-color in v4. A transitioned ring animates from its pre-focus value, which on a control that has never painted an outline is the initial currentColor: the control’s own text colour. On a filled variant that is a near-white ring for the first 150ms, which is no focus indicator at all on exactly the controls that matter most. It also makes getComputedStyle read the previous colour if you measure right after a Tab press, which is a reliable way to convince yourself a working ring is broken.\n\nName the properties instead:\n\ntransition-property: color, background-color, border-color, text-decoration-color;\n\n\n\n\n \n \n Preview\n \n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n \n \n default\n \n\n \n \n\n \n \n\n \n \n \n \n\n\n\n\n Try it on any demo on this site. Every preview panel now carries a shape button next to the dark-mode toggle. It cycles default → brutal → soft, which are nothing but different values for the tokens above — no component selector is involved. Flip it on any component page to watch the token layer move.\n\n\n\n\nFlat Theme in Six Lines\n\n:root {\n --elevation-control: none;\n --elevation-raised: none;\n --elevation-overlay: none;\n --elevation-none: none;\n --control-radius: 0.25rem;\n --surface-radius: 0.25rem;\n}\n\n\nEvery shadow in the library disappears and every box takes a 4px corner. The checkbox and the switch keep their own roles, which is the point of separating them.\n\nBrutalist Theme in Twelve Lines\n\n:root {\n --control-radius: 0;\n --surface-radius: 0;\n --mark-radius: 0;\n --pill-radius: 0;\n --focus-ring-width: 4px;\n --focus-ring-offset: 3px;\n --focus-ring-color: var(--foreground);\n --elevation-control: none;\n --elevation-raised: none;\n --elevation-overlay: none;\n --label-weight: 700;\n --value-weight: 900;\n}\n\n\nSquare everything, thicken the ring and push it off the edge, drop every shadow, and make labels and values shout. No component selector anywhere.\n\n\n\n \n \n Preview\n \n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n \n \n default\n \n\n \n \n\n \n \n\n \n \n \n \n\n\n\n\n\nWhere the Declarations Go\n\nPut them in a plain, unlayered :root block in your theme.css — that is what the installer generates, and unlayered CSS wins over the engine’s @theme defaults whatever the import order.\n\nDo not wrap them in @theme: that emits into @layer theme alongside the engine’s own defaults, where source order becomes the only tie-breaker. Do not rename them into Tailwind’s namespaces (--radius-*, --shadow-*) either — a @theme { --radius-*: initial } in an app would wipe them.\n\n/* app/assets/tailwind/theme.css */\n:root {\n --surface-radius: 1rem;\n}\n\n\n\n\nRecoloring Control Marks\n\nThe checkbox tick, the checkbox dash, the radio dot, the switch thumb and the select chevron are whole SVG data URIs rather than a color token, and that is forced by CSS, not a choice: var() cannot be interpolated into url(), a data URI is a separate SVG document so currentColor never resolves inside it, and mask-image would mask the whole element — box, border and shadow — along with the glyph. So each mark is exposed as its own property.\n\n\n \n \n Token\n Mark\n \n \n \n \n --checkbox-mark-image\n Checkbox tick\n \n \n --checkbox-indeterminate-image\n Checkbox dash\n \n \n --radio-mark-image\n Radio dot\n \n \n --switch-thumb-image\n Switch thumb\n \n \n --select-chevron-image\n Select chevron\n \n \n\n\nThey theme like every other token: set one in :root (or in any theme block) and every control picks it up. The engine keeps its own artwork in the use-site fallback rather than declaring it on the control, precisely so that a global declaration wins.\n\n:root {\n --checkbox-mark-image: url(\"data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='%23ffffff' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M8 2l1.8 4.2L14 8l-4.2 1.8L8 14l-1.8-4.2L2 8l4.2-1.8z'/%3e%3c/svg%3e\");\n}\n\n\nPer-instance opt-in\n\nA light --primary makes the default white ink measure about 1.15:1 against the checked fill. If that is a one-off rather than a theme-wide decision, one attribute fixes it with no CSS at all:\n\n<%= f.check_box :terms, data: { component: \"checkbox\", mark: \"dark\" } %>\n\n\ndata-mark=\"dark\" works on the checkbox, radio, switch and select; data-mark=\"light\" is also available on the select. Both are declared on the control, so an explicit per-instance opt-in beats a global default — which is the right way round.\n\nThe select chevron carries a different default per color scheme, because gray-500 alone is low-contrast on a dark field. That default is inherited rather than declared on the control, so one :root line still retints it in both schemes. If you want a different ink per scheme, say so explicitly:\n\n:root { --select-chevron-image: url(\"…dark ink…\"); }\n.dark { --select-chevron-image: url(\"…light ink…\"); }\n\n\n\n\n \n \n Preview\n \n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n \n \n default\n \n\n \n \n\n \n \n\n \n \n \n \n\n\n\n\n\nDark Mode\n\nDark-mode differences are token values too, so you rarely need a .dark twin of a component rule. Set the token inside your own .dark block:\n\n.dark {\n --focus-ring-color: color-mix(in oklch, var(--ring) 70%, transparent);\n}\n\n\n--control-fill is the one to know about: the engine re-declares it under .dark on the fields themselves, so overriding the dark field background needs a selector that reaches the control.\n\n.dark [data-component=\"input\"],\n.dark [data-component=\"textarea\"],\n.dark [data-component=\"select\"] {\n --control-fill: oklch(0.2 0.03 260);\n}\n\n\n\n\nPinning One Component\n\nEvery radius and elevation site also reads a component-level property that falls back to the role token, so you can pin one component without redefining a role. Role tokens are the public API; these exist for the one-off.\n\n:root {\n --card-radius: 0.75rem; /* cards only; everything else stays 0.5rem */\n --toast-shadow: none; /* toasts only */\n}\n\n\n\n \n \n Property\n Falls back to\n \n \n \n \n --button-radius, --input-radius, --textarea-radius, --select-radius, --badge-radius, --pagination-radius, --toggle-group-radius, --date-picker-radius, --menu-button-radius, --sidebar-item-radius, --calendar-cell-radius, --combobox-item-radius, --dropdown-menu-item-radius, --toast-action-radius, --toast-close-radius, --drawer-close-radius\n --control-radius\n \n \n --card-radius, --alert-radius, --table-radius, --stats-radius, --empty-radius, --fieldset-radius, --calendar-radius, --combobox-radius, --dropdown-menu-radius, --menu-button-content-radius, --date-picker-popover-radius, --sidebar-radius, --inset-radius, --avatar-radius, --toast-radius\n --surface-radius\n \n \n --checkbox-radius\n --mark-radius\n \n \n --radio-radius, --switch-radius\n --pill-radius\n \n \n --card-shadow, --stats-shadow\n --elevation-raised\n \n \n --combobox-shadow, --dropdown-menu-shadow, --menu-button-shadow, --date-picker-popover-shadow, --toast-shadow, --toast-hover-shadow, --drawer-shadow\n --elevation-overlay\n \n \n\n\n\n\nAuditing an Existing Theme\n\nmaquina:doctor scans an app’s CSS, views and JavaScript and prints every place that restates something the token layer now owns, plus the one pattern that breaks outright. It never edits anything.\n\nbin/rails maquina:doctor\n\n\nEach finding names the release it came from, so the report stays useful across upgrades rather than describing one migration. See Upgrading for what changed in each release.\n\n\n\nNext Steps\n\n\n \n \n Upgrading to 0.6.0\n \n \n The preflight shim fix, the scanner, and every breaking change.\n \n \n\n \n \n Form Components\n \n \n Where control radius, marks and focus rings show up first." }, { "id": "documentation-components-toast", "title": "Toast", "collection": { "label": "pages", "name": "Posts" }, "categories": "", "tags": "", "url": "/documentation/components/toast/", "content": "Preview\n \n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n \n \n default\n \n\n \n \n\n \n \n\n \n \n \n \n\n\n\n\nUsage\n\n<%= render "components/toast",\n title: "Scheduled: Catch up",\n description: "Friday, February 10, 2025 at 5:57 PM" %>\n\nExamples\n\nSuccess\n\n<%= render "components/toast",\n variant: :success,\n title: "Success!",\n description: "Your changes have been saved." %>\n\nError\n\n<%= render "components/toast",\n variant: :error,\n title: "Error",\n description: "There was a problem with your request." %>\n\nWarning\n\n<%= render "components/toast",\n variant: :warning,\n title: "Warning",\n description: "Your session is about to expire." %>\n\nWith Action\n\n<%= render "components/toast",\n title: "Event Created",\n description: "Your event has been scheduled.",\n content: capture { %>\n <%= render "components/toast/action", label: "Undo", href: "#" %>\n<% } %>\n\nAPI Reference\n\nToast\n\n\n \n \n Parameter\n Type\n Default\n Description\n \n \n \n \n variant\n Symbol\n :default\n :default, :success, :info, :warning, :error; :destructive is accepted as an alias of :error\n \n \n title\n String\n nil\n Toast title text\n \n \n description\n String\n nil\n Toast description text\n \n \n icon\n Symbol\n nil\n Icon name (auto-selected by variant)\n \n \n duration\n Integer\n 5000\n Auto-dismiss time in ms\n \n \n dismissible\n Boolean\n true\n Show close button\n \n \n content\n String\n nil\n HTML content via capture, or use block (e.g., action buttons)\n \n \n css_classes\n String\n \"\"\n Additional CSS classes\n \n \n html_options\n Hash\n {}\n Additional HTML attributes\n \n \n\n\n\nToast Title\n\n\n \n \n Parameter\n Type\n Default\n Description\n \n \n \n \n text\n String\n nil\n Title text\n \n \n content\n String\n nil\n HTML content via capture, or use block\n \n \n css_classes\n String\n \"\"\n Additional CSS classes\n \n \n html_options\n Hash\n {}\n Additional HTML attributes\n \n \n\n\n\nToast Description\n\n\n \n \n Parameter\n Type\n Default\n Description\n \n \n \n \n text\n String\n nil\n Description text\n \n \n content\n String\n nil\n HTML content via capture, or use block\n \n \n css_classes\n String\n \"\"\n Additional CSS classes\n \n \n html_options\n Hash\n {}\n Additional HTML attributes\n \n \n\n\n\nToaster\n\nThe toaster is the container that holds and positions toast notifications. Place it once in your layout.\n\n<%= render "components/toaster", position: :bottom_right,\n content: toast_flash_messages %>\n\n\n \n \n Parameter\n Type\n Default\n Description\n \n \n \n \n position\n Symbol\n :bottom_right\n :top_left, :top_right, :bottom_left, :bottom_right\n \n \n content\n String\n nil\n Pre-rendered toasts (e.g., flash messages)\n \n \n css_classes\n String\n \"\"\n Additional CSS classes\n \n \n html_options\n Hash\n {}\n Additional HTML attributes\n \n \n\n\n\nToast Action\n\n\n \n \n Parameter\n Type\n Default\n Description\n \n \n \n \n label\n String\n required\n Button/link text\n \n \n href\n String\n nil\n Link URL (renders button if nil)\n \n \n method\n Symbol\n nil\n HTTP method for Turbo\n \n \n css_classes\n String\n \"\"\n Additional CSS classes\n \n \n html_options\n Hash\n {}\n Additional HTML attributes\n \n \n\n\n\nHelper Methods\n\n\n \n \n Method\n Description\n \n \n \n \n toast_flash_messages(exclude: [])\n Renders all flash messages as toasts\n \n \n toast(variant, title, **options)\n Renders a single toast\n \n \n toast_success(title, **options)\n Shorthand for success variant\n \n \n toast_error(title, **options)\n Shorthand for error variant\n \n \n toast_warning(title, **options)\n Shorthand for warning variant\n \n \n toast_info(title, **options)\n Shorthand for info variant\n \n \n\n\nJavaScript API\n\nThe toaster exposes a global Toast object for creating toasts from JavaScript:\n\nToast.success(\"Message saved!\")\nToast.error(\"Something went wrong\", { description: \"Please try again\" })\nToast.destructive(\"Record deleted\") // alias of Toast.error\nToast.info(\"New update available\", { duration: 10000 })\nToast.warning(\"Session expiring soon\")\nToast.show(\"Custom message\", { variant: \"default\" })\nToast.dismiss(toastId)\nToast.dismissAll()" }, { "id": "documentation-components-toggle-group", "title": "Toggle Group", "collection": { "label": "pages", "name": "Posts" }, "categories": "", "tags": "", "url": "/documentation/components/toggle-group/", "content": "Preview\n \n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n \n \n default\n \n\n \n \n\n \n \n\n \n \n \n \n\n\n\n\nUsage\n\n<%= render "components/toggle_group", type: :single, value: "center" do %>\n <%= render "components/toggle_group/item", value: "left", aria_label: "Align left" do %>\n <%= icon_for :align_left, class: "size-4" %>\n <% end %>\n <%= render "components/toggle_group/item", value: "center", aria_label: "Align center", pressed: true do %>\n <%= icon_for :align_center, class: "size-4" %>\n <% end %>\n <%= render "components/toggle_group/item", value: "right", aria_label: "Align right" do %>\n <%= icon_for :align_right, class: "size-4" %>\n <% end %>\n<% end %>\n\nExamples\n\nMultiple Selection\n\n<%= render "components/toggle_group", type: :multiple, value: ["bold", "italic"] do %>\n <%= render "components/toggle_group/item", value: "bold", aria_label: "Bold", pressed: true do %>\n <%= icon_for :bold, class: "size-4" %>\n <% end %>\n <%= render "components/toggle_group/item", value: "italic", aria_label: "Italic", pressed: true do %>\n <%= icon_for :italic, class: "size-4" %>\n <% end %>\n <%= render "components/toggle_group/item", value: "underline", aria_label: "Underline" do %>\n <%= icon_for :underline, class: "size-4" %>\n <% end %>\n<% end %>\n\nOutline Variant\n\n<%= render "components/toggle_group", type: :single, variant: :outline do %>\n <%= render "components/toggle_group/item", value: "list", aria_label: "List view" do %>\n <%= icon_for :list, class: "size-4" %>\n <% end %>\n <%= render "components/toggle_group/item", value: "grid", aria_label: "Grid view" do %>\n <%= icon_for :grid, class: "size-4" %>\n <% end %>\n<% end %>\n\nWith Text Labels\n\n<%= render "components/toggle_group", type: :single, size: :lg do %>\n <%= render "components/toggle_group/item", value: "day", pressed: true do %>\n Day\n <% end %>\n <%= render "components/toggle_group/item", value: "week" do %>\n Week\n <% end %>\n <%= render "components/toggle_group/item", value: "month" do %>\n Month\n <% end %>\n<% end %>\n\nAPI Reference\n\nToggle Group\n\n\n \n \n Parameter\n Type\n Default\n Description\n \n \n \n \n type\n Symbol\n :single\n :single or :multiple selection\n \n \n variant\n Symbol\n :default\n :default or :outline\n \n \n size\n Symbol\n :default\n :sm, :default, :lg\n \n \n value\n String/Array\n nil\n Initial selected value(s)\n \n \n disabled\n Boolean\n false\n Disable all items\n \n \n css_classes\n String\n \"\"\n Additional CSS classes\n \n \n html_options\n Hash\n {}\n Additional HTML attributes\n \n \n\n\n\nToggle Group Item\n\n\n \n \n Parameter\n Type\n Default\n Description\n \n \n \n \n value\n String\n required\n Value when selected\n \n \n pressed\n Boolean\n false\n Initial pressed state\n \n \n disabled\n Boolean\n false\n Disable this item\n \n \n aria_label\n String\n nil\n Accessible label for icon-only items\n \n \n css_classes\n String\n \"\"\n Additional CSS classes\n \n \n html_options\n Hash\n {}\n Additional HTML attributes\n \n \n\n\nBuilder Helper\n\nThe toggle_group helper renders the group and its items in one call:\n\n<%= toggle_group type: :multiple, variant: :outline do |group| %>\n <% group.item value: "bold", icon: :bold, aria_label: "Toggle bold" %>\n <% group.item value: "italic", icon: :italic, aria_label: "Toggle italic" %>\n<% end %>\n\nOr fully data-driven with toggle_group_simple:\n\n<%= toggle_group_simple type: :single, items: [\n { value: "left", icon: :align_left, aria_label: "Align left" },\n { value: "center", icon: :align_center, aria_label: "Align center" },\n { value: "right", icon: :align_right, aria_label: "Align right" }\n] %>" }, { "id": "documentation-components-upgrading", "title": "Upgrading", "collection": { "label": "pages", "name": "Posts" }, "categories": "", "tags": "", "url": "/documentation/components/upgrading/", "content": "What breaks between releases, and what to do about it.\n\n\n\n0.7.0 → 0.7.1\n\nNo API changes. Four fixes reported by a consuming app, all of which either correct themselves on upgrade or take one line of theme CSS.\n\nbundle update maquina-components\nbin/rails maquina:doctor\n\n\nThe doctor gained five rules for this release, and every finding is now tagged with the release it came from.\n\nA required field with no placeholder no longer paints the error state\n\nThe invalid rule for input and textarea was keyed on :invalid:not(:placeholder-shown). That guard only works on a field that has a placeholder: without one :placeholder-shown never matches, so its negation is always true and an empty required field matched :invalid from first paint — red before focus, before blur, before submit, and with no aria-invalid, so the visual and assistive channels disagreed. The date picker had the same shape through a bare input:invalid.\n\nBoth now key on :user-invalid, which only matches once the reader has actually interacted with the field. The destructive outline is also gated behind :focus-visible now, rather than painting a permanent halo on a resting field.\n\nIf you were relying on the old behaviour — that is, you render server-side errors and never set aria-invalid — the border will stop appearing. Set it explicitly:\n\n<%= f.email_field :email, data: { component: \"input\" },\n aria: { invalid: @user.errors[:email].any? } %>\n\n\nThe doctor reports this as breaking / invalid-styling-without-aria, and flags remaining required-without-placeholder fields as review / required-without-placeholder.\n\nField error text has its own colour token\n\n[data-form-part=\"error\"] painted --destructive-foreground, which is the colour meant to sit on a destructive fill — every other use of that token in the engine pairs it with a --destructive background. A field error is text on a card, so under a saturated (shadcn-style) palette it rendered near-white on white and the message was simply not there.\n\nIt now reads --destructive-text, defaulting to --destructive-foreground, and the invalid border reads --destructive-border, defaulting to --destructive.\n\nNothing to do if you use the palette the installer wrote. If your --destructive is a saturated red and --destructive-foreground is near-white, add two lines:\n\n:root {\n --destructive-text: var(--destructive);\n --destructive-border: var(--destructive);\n}\n\n\nSee Theming for the full convention. The doctor measures your actual tokens against your --card and reports breaking / destructive-error-invisible when the error text cannot be read. If you worked around this with your own text-destructive utility, it reports cleanup / destructive-error-workaround.\n\nThe dropdown menu and menu button flip when they hit the fold\n\nNeither controller measured anything, so a trigger near the bottom of the window opened straight past it — and because the clipped items are the ones at the end of the menu, the destructive action was the first thing to disappear. Both now measure on open and set data-side themselves. The CSS for every side already shipped; nothing was choosing one.\n\nIf you carry your own flip controller, you can delete it — the doctor reports it as cleanup / app-level-dropdown-flip.\n\nEvery leaf partial accepts a block\n\nNine leaf partials rendered text || content and silently dropped a block, while nine others accepted one — so render \"components/alert\" do … end worked and render \"components/alert/title\" do … end, one line below it, produced an empty element with no error. All eighteen now take text:, content: or a block interchangeably, and text: \"\" consistently falls through to the block rather than rendering empty in half of them.\n\nThis is additive: anything that worked before still works.\n\n\n\n0.6.1 → 0.7.0\n\nNo breaking changes and nothing to migrate — an accessibility release. One deprecation, and a good deal of host-side code you can now delete.\n\nbundle update maquina-components\n\n\nWhat changes on its own\n\n\n Focus rings appear instantly. They used to fade in over 150ms from the control’s own text colour, because every component painting a token ring also carried transition-colors and Tailwind v4 folds outline-color into that utility. On a filled variant that meant a near-white ring for the first frames — no visible focus indicator on the highest-stakes controls in a page. If you built your own components against --focus-ring-*, they have the same latent bug; see Theming.\n Two triggers gain the chevron they never had. The dropdown menu trigger and the combobox trigger both asked for icon names the engine did not ship, and rendered nothing at all. Any as_child trigger you wrote purely to supply a chevron can collapse back to the default path — but keep the ones carrying their own content.\n A collapsed off-canvas sidebar leaves the tab order, and below 768px the sidebar reserves no layout. Both are structural now.\n Breadcrumbs collapse on available space. The width measurement never actually fired before — the last item’s flex-shrink absorbed the overflow, so the row reported a perfect fit at every width.\n\n\nDeprecated: collapse_after\n\nresponsive_breadcrumbs(..., collapse_after: 3) still accepts the argument and now ignores it. It existed only to fake collapsing while the measurement was broken, and it collapsed on item count alone — so it also collapsed a trail with plenty of room. Delete it from your calls; it goes away in 0.8.0.\n\nWorkarounds you can delete\n\nSeveral apps carry host-side code for the bugs above. Deleting it is the right outcome, not keeping it:\n\n\n a restated focus ring on buttons — especially a box-shadow one on a filled variant, which collides with the elevation each variant declares and is clipped by any overflow-hidden ancestor\n a low-specificity :focus-visible baseline standing in for engine rings that “did not paint”, and any rule restoring the ring on breadcrumb links\n a controller setting inert on the sidebar when it is off-canvas\n an unlayered @media (width < 768px) rule forcing the sidebar gap to 0\n\n\n\n\n0.5.1 → 0.6.0\n\nStart here, then run the scanner:\n\nbundle update maquina-components\nbin/rails maquina:doctor\n\n\nmaquina:doctor reads your CSS, views and JavaScript and prints file:line for every pattern this release changes, grouped BREAKING / REVIEW / CLEANUP. It never edits anything and never fails a build.\n\n\n\n1. Your theme.css Preflight Shim Now Flattens Alert and Toast Borders\n\nThis affects every existing app, and it fails silently. The theme.css shipped by earlier installers ends with an unlayered universal rule:\n\n/* 0.5.1 — as installed */\n* {\n border-color: var(--color-border);\n}\n\n\nIn 0.6.0 the engine’s rules live in @layer components. Unlayered CSS outranks every layer at any specificity, so that one rule now wins over the tinted borders on all alert and toast variants: a destructive alert’s border measures oklch(0.928 0.006 264) — plain --border — where 0.5.1 painted oklch(0.92 0.05 25).\n\nThe generator template is fixed, but the rule lives in your file. Wrap it:\n\n/* 0.6.0 — one line of nesting */\n@layer base {\n * {\n border-color: var(--color-border);\n }\n}\n\n\nmaquina:doctor reports this as breaking / unlayered-universal-rule. The same applies to any other unlayered * rule you have added.\n\n\n\n2. Utilities Passed Through css_classes Now Win\n\nEvery engine rule is flattened to specificity 0,1,0 and layered, so a Tailwind utility passed as css_classes: finally takes effect. It used to be silently swallowed — which means utilities you already pass may start applying.\n\n<%= render \"components/form\", css_classes: \"flex\" do %>\n\n\n\n \n \n Site\n 0.5.1\n 0.6.0\n \n \n \n \n Input with a width utility\n 448px\n 137px\n \n \n Form actions with a hidden utility\n display: flex\n display: none\n \n \n Form with a flex utility\n display: grid\n display: flex\n \n \n\n\nSearch your views for css_classes: before upgrading. Anything you passed as decoration and never saw is now live; delete what you did not mean.\n\n\n\n3. Radius and Elevation Defaults Normalize\n\nRadius now comes from four role tokens. Eight sites move:\n\n\n \n \n Component / part\n 0.5.1\n 0.6.0\n \n \n \n \n [data-component=\"card\"]\n 12px\n 8px\n \n \n [data-sidebar-part=\"inset\"] (variant inset)\n 12px\n 8px\n \n \n [data-sidebar-part=\"inset\"] [data-component=\"header\"] top corners\n 12px\n 8px\n \n \n [data-combobox-part=\"content\"] popover\n 6px\n 8px\n \n \n [data-dropdown-menu-part=\"content\"] popover\n 6px\n 8px\n \n \n [data-combobox-part=\"option\"]\n 4px\n 6px\n \n \n [data-dropdown-menu-part=\"item\"]\n 4px\n 6px\n \n \n [data-toast-part=\"close\"]\n 4px\n 6px\n \n \n\n\nFour elevation sites collapse from shadow-lg to --elevation-overlay, which resolves to shadow-md: the toast, the toast on hover, the drawer panel and the date-picker popover.\n\nEach site keeps a component-level escape hatch, so any one of them can be pinned without redefining a role. See Theming, or take the whole block from the appendix below.\n\n\n\n4. Focus Rings Are Outlines, and Buttons Finally Have Them\n\nThree changes in one:\n\n/* 0.5.1 — a box-shadow ring, on :focus as well as :focus-visible */\n[data-component=\"input\"]:focus,\n[data-component=\"input\"]:focus-visible {\n box-shadow: 0 0 0 2px var(--background), 0 0 0 4px var(--ring);\n}\n\n/* 0.6.0 onward — an outline, keyboard focus only, from tokens.\n Written as longhands since 0.7.0: the shorthand is invalid at\n computed-value time as a unit, so one unresolvable var() took the\n whole ring down and left outline-color: currentColor behind. */\n[data-component=\"input\"]:focus-visible {\n outline-width: var(--focus-ring-width);\n outline-style: var(--focus-ring-style);\n outline-color: var(--focus-ring-color);\n outline-offset: var(--focus-ring-offset);\n}\n\n\n\n Form fields no longer ring on a mouse click. The bare :focus half of each :focus, :focus-visible pair is gone; keyboard focus still rings.\n Rings are outline + outline-offset, uniformly 3px at offset 0. The sites that faked a backdrop band with 0 0 0 2px var(--background), 0 0 0 4px var(--ring) lose the band. An outline cannot be clipped by an ancestor’s overflow and never affects layout, which is why the drawer and the sidebar could not use a ring before.\n Six button variants gain a ring they never had. :focus-visible used to be declared before the variant rules at equal specificity, so every variant that set a background overwrote it: 2 of 16 buttons on the specimen page actually ringed. If your app restated a ring on buttons to work around this, delete it.\n\n\nIf a custom component of yours keys off the engine’s ring, read the tokens instead: --focus-ring-width, --focus-ring-offset, --focus-ring-style, --focus-ring-color.\n\n\n\n5. merge_component_data Precedence Narrows\n\nThe component used to win every key it set. Now it wins only its identity keys: :component, :variant, :size, and any key ending in _part or -part. :controller and :action still concatenate — the component’s tokens first, then yours. Everything else the caller wins.\n\n<%# 0.5.1: the toast's own state won, this did nothing %>\n<%# 0.6.0: renders data-state=\"exiting\" %>\n<%= render \"components/toast\", title: \"Saved\", data: { state: \"exiting\" } %>\n\n\nThe merged hash is also .compacted, so a nil value emits no attribute at all where it used to emit an empty one. false still renders \"false\" — that is a value, not an absence.\n\nRelated, and also reported by the doctor as breaking: a sidebar item now omits data-active entirely when it is inactive, instead of writing data-active=\"false\". Presence selectors no longer match:\n\n/* before */ [data-sidebar-part=\"menu-button\"][data-active] { }\n/* after */ [data-sidebar-part=\"menu-button\"][data-active=\"true\"] { }\n\n\n<!-- before --> <a data-[active]:bg-accent>\n<!-- after --> <a data-[active=true]:bg-accent>\n\n\n\n\n6. Surfaces Above the Page Stop Painting the Page Color\n\nAn alert, a calendar and the date-picker popover painted --background — the page. Anything floating above the page is a surface, so they now paint --card or --popover.\n\nIf your theme sets those to the same value, nothing moves. That is exactly why this went unnoticed: in the default light theme all three are white. In the default dark theme they separate.\n\nalert, calendar background (dark) oklch(0.13 0.028 261) → oklch(0.178 0.032 260)\n\n\nMeasured the old way, the calendar sat at ΔL 0.00 against the page — an invisible surface. Related: the outline and ghost buttons and the active pagination link now paint transparent instead of --background, so they work inside a card, which they previously did not.\n\nTo pin the old behavior, point the surface tokens at the page:\n\n:root {\n --popover: var(--background);\n --card: var(--background);\n}\n\n\n\n Checking surface-against-surface contrast? Use ΔL on the CIE L* axis, not a WCAG ratio. WCAG contrast is a text metric; on two adjacent large surfaces it reads a misleading ~1.1 and tells you nothing.\n\n\n\n\n7. Tinted Badges Lose a Stray Hairline\n\nBadge’s success / warning / destructive variants have always set border-color: transparent. The unlayered * shim from step 1 was overriding it with --border, so those badges carried a grey 1px outline they were never meant to have. Once the shim is layered, the intended transparent border shows through.\n\nNothing to do — but if you had compensated for the hairline elsewhere, remove the compensation.\n\n\n\nAppendix: Keeping the 0.5.1 Look\n\nEverything above is a value, so a single token block reverts the visual changes. Drop this into your theme.css and delete the lines you do not want.\n\n:root {\n /* Radius — the eight sites that moved */\n --card-radius: 0.75rem;\n --inset-radius: 0.75rem;\n --combobox-radius: 0.375rem;\n --dropdown-menu-radius: 0.375rem;\n --combobox-item-radius: 0.25rem;\n --dropdown-menu-item-radius: 0.25rem;\n --toast-close-radius: 0.25rem;\n\n /* Elevation — the four sites that collapsed shadow-lg → shadow-md */\n --toast-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);\n --toast-hover-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);\n --drawer-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);\n --date-picker-popover-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);\n\n /* Focus ring — the closest outline equivalent of the old two-step ring */\n --focus-ring-width: 2px;\n --focus-ring-offset: 2px;\n}\n\n\nTwo things this block cannot bring back, because they are not values:\n\n\n The backdrop band. The old ring drew --background under --ring inside a single box-shadow; an outline is one line. --focus-ring-offset: 2px leaves the same gap, showing whatever is actually behind the control.\n The mouse-click ring on form fields, and the absent ring on five button variants. Both were :focus-visible bugs, and both are fixed on purpose.\n\n\nRunning bin/rails generate maquina_components:install again is safe: it is idempotent, appends the shape/state token block only once, and never rewrites your palette.\n\n\n\nNext Steps\n\n\n \n \n Theming\n \n \n The full token table, ready-made themes, and pinning a single component.\n \n \n\n \n \n Components Overview\n \n \n Installation, setup, and the full component index." }, { "id": "documentation-engines", "title": "Engines", "collection": { "label": "pages", "name": "Posts" }, "categories": "", "tags": "", "url": "/documentation/engines/", "content": "Mountable Rails engines that add complete features to your app. Mount under a backstage path, configure a few options, and get production-ready functionality themed with maquina_components.\n\n\n\nAvailable Engines\n\n\n \n \n Maquina Newsletters\n \n \n Draft, approve, schedule, and batch-send HTML newsletters from a backstage area.\n \n \n\n\n\n\nWhat Is a Mountable Engine?\n\nA Rails engine is a miniature application that plugs into a host app. You mount it at a path in config/routes.rb, run its installer, and it brings its own models, controllers, views, and background jobs — while reusing your app’s database, authentication, and configuration.\n\n# config/routes.rb\nmount MaquinaNewsletters::Engine => \"/backstage/newsletters\"\n\n\nMaquina engines keep authentication in the host app’s hands (they inherit from a base controller you configure) and theme their UI with maquina_components, so they look like a native part of your app." }, { "id": "documentation-engines-maquina-newsletters", "title": "Maquina Newsletters", "collection": { "label": "pages", "name": "Posts" }, "categories": "", "tags": "", "url": "/documentation/engines/maquina-newsletters/", "content": "A mountable Rails 8 engine for drafting, approving, scheduling, and batch-sending HTML newsletters. Compose with Action Text, gate sends behind an approval step, schedule deliberately, and let a background job deliver in batches — all from a backstage area inside your own app.\n\n\n\nWhat Is This?\n\nMaquina Newsletters is a Rails engine you mount under a backstage path (e.g. /backstage/newsletters). It gives you a complete newsletter lifecycle without bringing in an external service:\n\n\n Draft and edit with the Action Text rich-text editor — Trix or Lexxy, your choice\n Image attachments and embeds via Active Storage\n Approval workflow — a newsletter can’t be sent straight from a draft\n Deliberate scheduling — pick the date, time, and batch size in one explicit step\n Batch sending — split delivery across days, or send to everyone at once\n Test sends to any address, plus a send-now override\n Per-issue exclusion list to drop specific recipients\n\n\nThe engine renders its own UI (themed with maquina_components) and resolves recipients from a model and scope you configure. Authentication stays in your hands — the engine inherits from a base controller you point it at.\n\n\n\nRequirements\n\n\n Rails 8\n Active Storage and Action Text configured in the host app\n image_processing (~> 2.0) plus an image processor — ruby-vips (recommended) or mini_magick\n A system image library: libvips (recommended) or ImageMagick\n \n macOS: brew install vips (or brew install imagemagick)\n Debian/Ubuntu: apt-get install libvips (or apt-get install imagemagick)\n \n \n tailwindcss-rails (engine UI theming) and maquina_components (host theming)\n lexxy — optional, for the Lexical-based editor (Rails 8.1+ can auto-configure it)\n\n\n\n\nQuick Start\n\n1. Add the Gem\n\n# Gemfile\ngem \"maquina_newsletters\", \"~> 1.5\"\n\n\nbundle install\n\n\n2. Mount the Engine\n\n# config/routes.rb\nmount MaquinaNewsletters::Engine => \"/backstage/newsletters\"\n\n\n3. Run the Installer\n\nbin/rails generate maquina_newsletters:install\nbin/rails db:migrate\n\n\nThe installer sets up the engine’s migrations and, if they aren’t already present, runs active_storage:install and action_text:install for you.\n\n4. Add Image Processing\n\n# Gemfile\ngem \"image_processing\", \"~> 2.0\"\ngem \"ruby-vips\" # or: gem \"mini_magick\"\n\n\nThen bundle install and install the system library (see Requirements).\n\n5. Wire Up Tailwind\n\n/* app/assets/tailwind/application.css */\n@import \"tailwindcss\";\n@import \"../builds/tailwind/maquina_newsletters\";\n\n\nbin/rails tailwindcss:build # or tailwindcss:watch in development\n\n\nKeep app/assets/builds/* in .gitignore and rebuild on each machine.\n\n6. Set the Mailer Host\n\nSo image URLs in delivered emails are absolute:\n\n# config/environments/production.rb\nconfig.action_mailer.default_url_options = { host: \"newsletters.example.com\" }\n\n\n\n\nConfiguration\n\nCreate an initializer to tell the engine who receives newsletters and how it’s protected:\n\n# config/initializers/maquina_newsletters.rb\nMaquinaNewsletters.configure do |config|\n # Recipient resolution — which records receive a newsletter.\n config.recipient_model = \"User\" # constantized at use-time\n config.recipient_scope = :active # a scope returning a relation\n config.recipient_email_attr = :email_address # the email column\n\n # Base controller — see \"Authentication\" below.\n config.base_controller_class = \"BackstageController\"\n\n # Optional HTTP Basic Auth (off by default)\n config.http_basic_auth_enabled = true\n config.http_basic_auth_user = ENV[\"NEWSLETTERS_USER\"]\n config.http_basic_auth_password = ENV[\"NEWSLETTERS_PASSWORD\"]\nend\n\n\nIf the initializer is absent, the defaults are:\n\n\n \n \n Setting\n Default\n \n \n \n \n recipient_model\n \"User\"\n \n \n recipient_scope\n :active\n \n \n recipient_email_attr\n :email_address\n \n \n base_controller_class\n \"ActionController::Base\"\n \n \n HTTP Basic Auth\n disabled\n \n \n\n\n\n\nAuthentication\n\nThe engine does not provide authentication — that’s the host app’s job. Every engine controller inherits from a base controller you configure by name:\n\nconfig.base_controller_class = \"BackstageController\"\n\n\nPoint it at an already-authenticated controller in your app (session checks, etc.) and every engine route is protected automatically.\n\nFor apps whose base controller doesn’t authenticate, the engine ships an optional HTTP Basic Auth fallback:\n\n\n Enabled with credentials — challenges with HTTP Basic Auth\n Enabled without credentials — fails closed (401 on every request)\n Disabled — no built-in auth; relies on the base controller\n\n\nDon’t stack both methods — pick one.\n\n\n\nThe Newsletter Lifecycle\n\nA newsletter moves through four states:\n\n\n \n \n State\n What happens\n \n \n \n \n Draft\n Create and edit content (subject + Action Text body). Saving creates a draft; no send time is set.\n \n \n Approved\n Approve a draft when it’s ready. You can’t send from a draft.\n \n \n Scheduled\n On an approved issue, set the send timing and batch size.\n \n \n Sending → Sent\n A background job delivers. A sending/sent issue can’t be edited.\n \n \n\n\nYou can move backward too: Back to draft (from approved/scheduled/sent) and Unschedule (from scheduled back to approved).\n\nScheduling\n\nThe schedule form appears on an approved issue and takes three inputs:\n\n\n Date — date picker, today onward (no past dates)\n Time — 8:00 AM to 8:00 PM in 30-minute increments\n Batch size — recipients per batch. 0 sends to everyone at once; a positive number splits the send across days, one batch per day.\n\n\nIf the chosen date/time has already passed, it auto-rolls forward to the next 30-minute slot and the confirmation says so. Once scheduled, a read-only summary shows Recipients / Scheduled at / Batch size / Sent at.\n\nSend Now & Test Send\n\n\n Send now — an overflow (⋮) action that delivers immediately to all recipients behind a confirmation, bypassing scheduling.\n Test send — available while drafting/approving/scheduling. Sends exactly one email to any address you type, ignoring batch size and schedule, without changing the issue’s state. Ideal for preview validation.\n\n\nRecipients\n\nRecipients are resolved at send time from your configured model and scope (e.g. User.active), minus the per-issue exclusion list. The resulting addresses are downcased, de-duplicated, and sorted for stable batching.\n\n\n\nEditors\n\nThe host chooses the Action Text editor via config.action_text.editor:\n\n\n :trix — Rails default, no extra setup\n :lexxy — Lexical-based; install the lexxy gem and wire up its JS/CSS\n\n\nFor Lexxy with importmaps:\n\n# config/importmap.rb\npin \"lexxy\", to: \"lexxy.js\"\npin \"@rails/activestorage\", to: \"activestorage.esm.js\"\n\n\n// app/javascript/application.js\nimport * as ActiveStorage from \"@rails/activestorage\"\nimport \"lexxy\"\nActiveStorage.start()\n\n\n<%# in your layout, after the CSS build %>\n<%= stylesheet_link_tag \"lexxy\" %>\n\n\nOn Rails 8.1, installing the lexxy gem auto-sets config.action_text.editor = :lexxy; set it to :trix to override.\n\n\n\nNext Steps\n\n\n \n \n GitHub Repository\n \n \n Source code, issues, and contribution guidelines.\n \n \n\n \n \n Maquina Components\n \n \n The UI library that themes the engine's backstage views.\n \n \n\n \n \n Maquina Generators\n \n \n Generate the authentication that protects your backstage." }, { "id": "documentation-generators", "title": "Maquina Generators", "collection": { "label": "pages", "name": "Posts" }, "categories": "", "tags": "", "url": "/documentation/generators/", "content": "Rails generators that produce standalone application code. No runtime dependency — generate once, own the code forever. Delete the gem when you’re done.\n\n\n\nWhat Is This?\n\nAfter rails new, every developer follows the same steps: configure authentication, set up request throttling, wire up a job queue, add error tracking. These steps aren’t gaps in the framework — they’re workflow choices that are repetitive and time-consuming.\n\nMaquina Generators make the post-rails new setup as deterministic as the framework itself. The gem is development-only. Everything it produces lives in your app and is yours to modify.\n\nrails new myapp --css tailwind\nbundle add maquina-generators --group development\nrails generate maquina:app --auth clave\nbin/rails db:migrate\nbin/dev\n\n\nFive commands. Auth, multi-tenancy, roles, job queue, error tracking, request protection — all generated, all yours.\n\n\n\n\n\nQuick Start\n\n1. Create a Rails App\n\nrails new myapp --css tailwind\ncd myapp\n\n\n2. Add the Gem\n\nbundle add maquina-generators --group development\n\n\n3. Run the App Generator\n\nrails generate maquina:app --auth registration\n\n\n4. Finish Setup\n\nbin/rails db:migrate\nbin/rails credentials:edit\n# Add: backstage: { username: admin, password: your_password }\nbin/dev\n\n\n\n\nAvailable Generators\n\n\n \n \n Generator\n Command\n Purpose\n \n \n \n \n App\n rails g maquina:app\n Full application setup (orchestrator)\n \n \n Clave\n rails g maquina:clave\n Passwordless email-code authentication\n \n \n Registration\n rails g maquina:registration\n Password-based auth with accounts and roles\n \n \n Rack Attack\n rails g maquina:rack_attack\n Request protection and IP throttling\n \n \n Solid Queue\n rails g maquina:solid_queue\n Background job processing\n \n \n Solid Errors\n rails g maquina:solid_errors\n Error tracking dashboard\n \n \n Mission Control\n rails g maquina:mission_control_jobs\n Job queue monitoring dashboard\n \n \n\n\n\n\nThe App Generator\n\nThe orchestrator. Runs after rails new and configures a complete, production-ready application in a single command.\n\nrails g maquina:app --auth clave --prefix /admin --port 3000\n\n\nWhat It Does\n\n\n Adds gems — brakeman, standard, rails-i18n, maquina-components, aws-sdk-s3\n Creates configs — Procfile.dev, .rubocop.yml, .standard.yml\n Configures environments — letter_opener for dev, APPLICATION_HOST for production\n Installs Rails features — Action Text, Active Storage, Turbo morphing\n Runs auth generator — your choice of clave, registration, or none\n Runs sub-generators — rack_attack, solid_queue, mission_control_jobs, solid_errors\n Installs Solid adapters — Solid Queue, Solid Cache, Solid Cable, Solid Errors\n Installs Maquina Components — UI library ready to use\n Creates HomeController — with root route\n Sets up multi-database — primary, queue, cache, cable, errors\n\n\nOptions\n\n\n \n \n Option\n Default\n Description\n \n \n \n \n --auth\n none\n Authentication: none, clave, or registration\n \n \n --prefix\n /admin\n URL prefix for ops dashboards\n \n \n --port\n 3000\n Development server port\n \n \n\n\nGenerated Database Configuration\n\ndevelopment:\n primary:\n database: storage/development.sqlite3\n queue:\n database: storage/development_queue.sqlite3\n cache:\n database: storage/development_cache.sqlite3\n cable:\n database: storage/development_cable.sqlite3\n errors:\n database: storage/development_errors.sqlite3\n\n\n\n\nAuthentication: Clave (Passwordless)\n\nComplete passwordless authentication using email verification codes. Users receive a 6-digit code via email to sign in — no passwords to manage, no password resets to build.\n\nrails g maquina:clave\n\n\nWhat You Get\n\nModels:\n\n Account — multi-tenant container (has_many :users)\n User — with role enum (member/admin), account association, blocking support\n Session — browser session tracking with IP and user agent\n EmailVerification — verification codes with expiry and attempt tracking\n Current — ActiveSupport::CurrentAttributes with session, user, and account\n\n\nControllers:\n\n SessionsController — email entry for sign-in\n Session::VerificationsController — code verification\n Session::VerificationResendsController — resend with 15-minute cooldown\n RegistrationsController — account creation (optional)\n Registration verification controllers\n\n\nAdditional:\n\n VerificationMailer — HTML + text email templates\n AuthenticationCleanupJob — daily cleanup of expired sessions and codes\n SessionTestHelper — sign_in_as(user) and sign_out for tests\n Full i18n support (English and Spanish)\n\n\nHow It Works\n\nUser enters email → receives 6-digit code → enters code → signed in\n\n\n\n Codes expire in 15 minutes\n 15-minute cooldown before resend\n Rate limited: 10 attempts per 3 minutes\n Sessions last 30 days (configurable)\n + characters blocked in emails to prevent alias attacks\n\n\nMulti-Tenancy\n\nEvery user belongs to an Account. The first user who creates an account becomes its admin.\n\n# Access anywhere in your app\nCurrent.user # The signed-in user\nCurrent.account # The user's account\nCurrent.user.admin? # Check role\n\n\nScoping Queries\n\nclass ProjectsController < ApplicationController\n def index\n @projects = Current.account.projects\n end\n\n def create\n @project = Current.account.projects.build(project_params)\n # ...\n end\n\n private\n\n def set_project\n @project = Current.account.projects.find(params[:id])\n end\nend\n\n\nOptions\n\n\n \n \n Option\n Default\n Description\n \n \n \n \n --skip-views\n false\n Skip view templates\n \n \n --skip-registration\n false\n Skip sign-up flow (sign-in only)\n \n \n\n\n\n\nAuthentication: Registration (Password-Based)\n\nPassword-based authentication that builds on Rails 8’s built-in rails generate authentication. Adds multi-tenancy with an Account model, user roles, and a registration flow.\n\nrails g maquina:registration\n\n\nWhat It Adds to Rails Auth\n\nRails 8’s authentication generator gives you login but no signup. Registration adds:\n\n\n Account model with has_many :users\n User gains belongs_to :account and role enum (admin/member)\n Current.account delegation\n RegistrationsController — creates Account + User in a single transaction\n Tailwind-styled views\n English and Spanish translations\n\n\nGenerated Models\n\nclass Account < ApplicationRecord\n has_many :users, dependent: :destroy\n validates :name, presence: true\nend\n\nclass User < ApplicationRecord\n has_secure_password\n has_many :sessions, dependent: :destroy\n belongs_to :account\n validates :name, presence: true\n enum :role, { member: \"member\", admin: \"admin\" }, default: \"member\"\nend\n\nclass Current < ActiveSupport::CurrentAttributes\n attribute :session\n delegate :user, to: :session, allow_nil: true\n delegate :account, to: :user, allow_nil: true\nend\n\n\nRegistration Flow\n\nclass RegistrationsController < ApplicationController\n allow_unauthenticated_access\n rate_limit to: 10, within: 3.minutes, only: :create\n\n def create\n ActiveRecord::Base.transaction do\n account = Account.create!(name: params[:account_name])\n user = account.users.create!(\n name: params[:name],\n email_address: params[:email_address],\n password: params[:password],\n role: :admin\n )\n end\n start_new_session_for user\n redirect_to root_path\n end\nend\n\n\nOptions\n\n\n \n \n Option\n Default\n Description\n \n \n \n \n --skip-views\n false\n Skip view templates\n \n \n\n\n\n\nRack Attack\n\nRequest protection with sensible defaults. Blocks common attack vectors and throttles abusive requests.\n\nrails g maquina:rack_attack\n\n\nDefault Protections\n\nBlocklists:\n\n PHP files and WordPress paths\n Sensitive files (.env, .git, .aws, .ssh)\n Scanner targets (/cgi-bin, /phpmyadmin, /actuator, /debug)\n\n\nThrottles:\n\n General: 300 requests per 5 minutes per IP (assets exempt)\n Login: 5 attempts per 20 seconds per IP\n\n\nSafelists:\n\n Localhost (127.0.0.1, ::1)\n\n\nAll rules live in config/initializers/rack_attack.rb. Edit directly.\n\n\n\nSolid Queue\n\nSets up Solid Queue as your Active Job backend with a separate database and Procfile integration.\n\nrails g maquina:solid_queue --database sqlite3\n\n\nConfiguration\n\n# config/solid_queue.yml\ndefault: &default\n dispatchers:\n - polling_interval: 1\n batch_size: 500\n workers:\n - queues: \"*\"\n threads: 3\n polling_interval: 0.1\n recurring:\n authentication_cleanup:\n class: AuthenticationCleanupJob\n schedule: every day at 3am\n\n\nOptions\n\n\n \n \n Option\n Default\n Description\n \n \n \n \n --database\n sqlite3\n Database adapter (sqlite3 or postgresql)\n \n \n\n\n\n\nSolid Errors\n\nError tracking dashboard with custom Tailwind views and HTTP basic auth.\n\nrails g maquina:solid_errors --prefix /admin\n\n\nWhat You Get\n\n\n Custom Tailwind-styled error views\n HTTP basic auth (credentials-first, ENV fallback)\n Severity badge helpers\n Clipboard and backtrace filter Stimulus controllers\n Shared admin navigation bar\n\n\nAuthentication\n\n# Checks in order:\n# 1. Rails.application.credentials.backstage.username / .password\n# 2. ENV[\"SOLID_ERRORS_USER\"] / ENV[\"SOLID_ERRORS_PASSWORD\"]\n\n\nSet up credentials:\n\nbin/rails credentials:edit\n\n\nbackstage:\n username: admin\n password: your_secure_password\n\n\nOptions\n\n\n \n \n Option\n Default\n Description\n \n \n \n \n --prefix\n required\n URL prefix (e.g., /admin)\n \n \n --user-env-var\n SOLID_ERRORS_USER\n Custom env var for username\n \n \n --password-env-var\n SOLID_ERRORS_PASSWORD\n Custom env var for password\n \n \n --copy-views\n true\n Include custom Tailwind views\n \n \n\n\n\n\nMission Control Jobs\n\nJob queue monitoring dashboard with custom Tailwind views. 41 view files styled to match your application.\n\nrails g maquina:mission_control_jobs --prefix /admin\n\n\nWhat You Get\n\n\n Full Tailwind-styled dashboard for Solid Queue\n Job status badges, queue views, worker monitoring\n Recurring task management\n Shared admin navigation (links to Solid Errors)\n HTTP basic auth (same credentials as Solid Errors)\n\n\nOptions\n\n\n \n \n Option\n Default\n Description\n \n \n \n \n --prefix\n required\n URL prefix (e.g., /admin)\n \n \n --user-env-var\n MISSION_CONTROL_JOBS_USER\n Custom env var for username\n \n \n --password-env-var\n MISSION_CONTROL_JOBS_PASSWORD\n Custom env var for password\n \n \n --copy-views\n true\n Include custom Tailwind views\n \n \n\n\n\n\nArchitecture Overview\n\nAfter running maquina:app --auth clave, your project structure looks like this:\n\napp/\n controllers/\n concerns/\n authentication.rb # Session management\n sessions_controller.rb # Sign-in\n registrations_controller.rb # Sign-up\n home_controller.rb # Root page\n models/\n account.rb # Multi-tenant container\n user.rb # Roles + auth\n current.rb # Request context\n session.rb # Browser sessions\n email_verification.rb # Verification codes\n mailers/\n verification_mailer.rb # Email codes\n jobs/\n authentication_cleanup_job.rb # Daily cleanup\n\nconfig/\n initializers/\n rack_attack.rb # Request protection\n solid_errors.rb # Error tracking auth\n mission_control.rb # Job dashboard auth\n solid_queue.yml # Queue configuration\n\n\nSecurity Defaults\n\n\n Rate limiting on registration and login\n Rack Attack blocks scanners and bots\n All controllers require authentication by default\n Account scoping prevents cross-tenant data access\n\n\nOps Dashboards\n\n\n /admin/solid_errors — error tracking\n /admin/mission_control_jobs — job queue monitoring\n\n\nBoth protected with HTTP basic auth using shared backstage credentials.\n\n\n\nRole-Based Authorization\n\nUse the role enum to restrict actions:\n\nclass ProjectsController < ApplicationController\n before_action :require_admin, only: [:destroy]\n\n private\n\n def require_admin\n unless Current.user.admin?\n redirect_to projects_path, alert: t(\"flash.general.forbidden\")\n end\n end\nend\n\n\nRoles:\n\n admin — first user created with account, full access\n member — default role, restricted from destructive actions\n\n\n\n\nCustomization\n\nAll generated code lives in your app. Common customization points:\n\n\n \n \n What\n Where\n \n \n \n \n Redirect after login\n app/controllers/concerns/authentication.rb → after_authentication_url\n \n \n Session duration\n Change 30.days.from_now in authentication.rb\n \n \n Code expiration\n Change 15.minutes.from_now in verification controllers\n \n \n Resend cooldown\n EmailVerification::COOLDOWN_MINUTES (default: 15)\n \n \n View styling\n Edit view templates directly\n \n \n Email sender\n app/mailers/verification_mailer.rb\n \n \n Translations\n config/locales/clave.*.yml or registration.*.yml\n \n \n Rack Attack rules\n config/initializers/rack_attack.rb\n \n \n Dashboard credentials\n bin/rails credentials:edit → backstage:\n \n \n Queue config\n config/solid_queue.yml\n \n \n\n\n\n\nRequirements\n\n\n Ruby >= 3.2.0\n Rails >= 7.2\n Tailwind CSS (for generated views)\n\n\nThe gem has zero runtime dependencies. Add it to your development group, generate your code, and remove it.\n\n# Gemfile\ngroup :development do\n gem \"maquina-generators\"\nend\n\n\n\n\nNext Steps\n\n\n \n \n GitHub Repository\n \n \n View source code and contribute.\n \n \n\n \n \n Maquina Components\n \n \n UI components installed by the app generator.\n \n \n\n \n \n Rails Simplifier\n \n \n Keep generated code idiomatic with 37signals patterns.\n \n \n\n \n \n Rails MCP Server\n \n \n Give AI visibility into your generated codebase." }, { "id": "documentation", "title": "Documentation", "collection": { "label": "pages", "name": "Posts" }, "categories": "", "tags": "", "url": "/documentation/", "content": "Maquina is a growing collection of open-source tools extracted from production Rails applications. No complex build pipelines. No framework fatigue.\n\n\n\nWhy Maquina?\n\nRails developers who ship alone need tools that work together without adding complexity. Maquina provides:\n\n\n Generators — Production-ready app scaffolding. Authentication, job queues, error tracking, and security in one command.\n UI Components — ERB partials styled with Tailwind CSS 4.0. No React, no build step.\n Engines — Mountable Rails engines that drop complete features into your app.\n AI Tools — MCP servers and Claude Code plugins that understand your Rails codebase.\n Developer Utilities — Menu bar apps and CLI tools for your local environment.\n\n\nAll projects are MIT licensed, extracted from production apps, and built for the Rails way.\n\n\n\nPhilosophy\n\nNoBuild\n\nNo complex JavaScript build pipelines. Ship CSS and JS directly with importmaps and Tailwind CSS. Every Maquina tool follows this principle.\n\nSingle Developer\n\nOne developer can build and maintain the entire application. Tools should reduce complexity, not add it.\n\nCRUD Excellence\n\nMost applications are CRUD at their core. Maquina tools make common patterns elegant and maintainable.\n\n\n\nProjects\n\nGenerators\n\nRails generators that produce standalone application code with no runtime dependency. Authentication (passwordless or password-based), multi-tenancy, Rack Attack, Solid Queue, error tracking, and job dashboards — all configured in a single command.\n\nbundle add maquina_generators --group development\nrails generate maquina:app --auth clave\n\n\nView Generators Documentation\n\nUI Components\n\nProduction-ready components for Rails applications. ERB partials with strict locals, Tailwind CSS 4.0 styling, and Stimulus controllers only where needed.\n\nbundle add maquina_components\nrails generate maquina_components:install\n\n\nBrowse Components\n\nEngines\n\nMountable Rails engines that add complete features to your app. Mount under a backstage path, run the installer, and get production-ready functionality themed with maquina_components.\n\n# config/routes.rb\nmount MaquinaNewsletters::Engine => \"/backstage/newsletters\"\n\n\nView Engines\n\nAI Tools\n\nMCP servers and Claude Code plugins that let AI assistants understand your Rails projects. Analyze models, routes, schemas, simplify code, and coordinate changes across your editor.\n\n\n \n \n Tool\n Type\n Purpose\n \n \n \n \n Rails MCP Server\n MCP Server\n Let LLMs analyze your Rails codebase\n \n \n Neovim MCP Server\n MCP Server\n Coordinate buffer changes with AI assistants\n \n \n Rails Simplifier\n Plugin\n Code simplification with 37signals patterns\n \n \n Rails Upgrade Assistant\n Plugin\n Generate upgrade guides for Rails 7.0 through 8.1\n \n \n Maquina UI Standards\n Plugin\n Build consistent UIs with maquina_components\n \n \n\n\nBrowse all AI Tools →\n\nDeveloper Tools\n\nMenu bar apps and CLI utilities for your local development environment.\n\n\n \n \n Tool\n Purpose\n \n \n \n \n Redis Menu\n Manage local Redis instances from your menu bar\n \n \n Mongo Menu\n Manage local MongoDB instances from your menu bar\n \n \n Git Continuity\n Transfer work-in-progress between machines\n \n \n\n\nBrowse all Developer Tools →\n\n\n\nCommunity\n\nAll projects are on GitHub under the maquina-app organization. Issues, pull requests, and contributions welcome." }, { "id": "documentation-nexo-concurrency", "title": "Concurrency", "collection": { "label": "pages", "name": "Posts" }, "categories": "", "tags": "", "url": "/documentation/nexo/concurrency/", "content": "Async is entirely optional. Nexo installs and runs synchronously with no async gem present, and only complains if you actually use a concurrency feature. The async gem is a soft dependency — add it yourself when you want fan-out:\n\ngem \"async\", \"~> 2.0\"\n\n\nTwo facts make this cheap:\n\n\n LLM calls are already async-compatible. ruby_llm speaks HTTP over Faraday’s net/http adapter, which yields on socket I/O under Ruby’s fiber scheduler. Loops::RubyLLM therefore runs unchanged inside a reactor — no API change, no rewrite. Wrapping a single agent.prompt in Async {} gains nothing; async only pays off under fan-out.\n The value Nexo adds is rate-bounded fan-out. Nexo.concurrent bounds in-flight work so you don’t trip provider rate limits, and propagates the first error instead of swallowing it.\n\n\n\n\nNexo.concurrent — bounded fan-out\n\n# 100 docs, but never more than 8 provider calls in flight; results in doc order.\nresults = Nexo.concurrent(max_in_flight: 8) do |c|\n Document.find_each { |d| c.add { SummarizeDocument.run(doc_id: d.id, text: d.body).result } }\nend\n\n\nEvery block added with c.add { … } runs inside one async reactor, capped at max_in_flight in flight (an Async::Semaphore) and coordinated by an Async::Barrier. Results come back as an Array in submission order (not completion order). On the first task that raises, that error is re-raised and the remaining in-flight tasks are stopped — errors are never swallowed. max_in_flight defaults to Nexo.config.max_in_flight (8) and is the single most important knob for staying under provider rate limits.\n\nUsing Nexo.concurrent with async not installed raises Nexo::MissingDependencyError with install guidance.\n\nInside a durable workflow, Workflow#checkpoint_all is the workflow-durability flavored sibling of Nexo.concurrent: it drives this same bounded fan-out but persists each step to the run’s state as it lands, so a resume only re-runs what never completed. See Parallel checkpoints in the durable-workflows guide.\n\n\n\nSandboxes::Local offload\n\nUnder a reactor, blocking file/subprocess I/O would stall every other fiber. Flip the switch and Sandboxes::Local offloads its read/write/glob/shell to a worker thread:\n\nNexo.configure { |c| c.concurrency = :async } # default is :threaded\n\n\nThe decision is driven by config, not by scheduler detection: under :async the blocking block runs on a worker thread so the reactor keeps serving other fibers; under :threaded (the default) it runs inline with zero overhead — byte-for-byte the synchronous behavior. Offloading changes neither return values nor the security properties: the path-escape guard, narrowed ENV, and Timeout-wrapped subprocess are all preserved. (Sandboxes::Virtual is pure memory and Sandboxes::Remote is already HTTP/fiber-friendly — neither needs offload.)\n\n\n\nWorkflow buffered emit\n\nEach emit normally persists immediately. Under a reactor that per-event DB write blocks the whole loop, so Workflow.run takes a buffer_events: flag (default Nexo.config.buffer_workflow_events, false):\n\nrun = SummarizeDocument.run({doc_id: 1, text: body}, buffer_events: true)\n# events buffer in memory and flush to the store exactly once, on completion\n\n\nWith buffering on, events accumulate in memory and flush in a single save_events! at the end of the run (on both success and failure). The default (unbuffered) behavior is unchanged.\n\n\n\nRunning under Rails / a fiber server\n\nAsync DB work is the sharp edge. Under a fiber server such as Falcon, many concurrent queries can exhaust the ActiveRecord connection pool, so:\n\n\n Raise DB_POOL (the connection-pool size) to cover your in-flight concurrency.\n On Rails 7.1+, consider config.active_record.async_query_executor.\n Prefer buffer_events: true for workflows so each run writes its event log once instead of per event.\n\n\nNote that DB work under a reactor is offloaded/pooled, not truly fiber-async — Nexo does not ship a fiber-native DB driver. For server setup (Falcon, the fiber scheduler), see the async guide.\n\n\n\nNext steps\n\n\n \n \n Loops\n \n \n The per-agent engine that concurrency fans out.\n \n \n\n \n \n Workflows\n \n \n Structure fan-out work into a finite, inspectable run." }, { "id": "documentation-nexo-durable-workflows", "title": "Durable workflows", "collection": { "label": "pages", "name": "Posts" }, "categories": "", "tags": "", "url": "/documentation/nexo/durable-workflows/", "content": "A long-running or human-in-the-loop workflow can pause durably and continue later — possibly in another process — without re-running completed, already-paid-for work. Three small primitives compose over the existing run persistence (no step-graph engine, no replay log, no scheduler):\n\n\n checkpoint(name) { … } runs its block once and stores the json-serializable result under name in the run’s state. On a later run/resume of the same run, a present checkpoint returns the stored value without re-running the block. This is the tool that makes resume cheap and side-effect-safe.\n suspend!(reason:, resume_key: nil) pauses the run: it marks the run \"suspended\" (a non-failure outcome, distinct from \"failed\") and returns it to the caller — Workflow.run does not raise. Call it outside a checkpoint.\n Workflow.resume(run_id, input = {}) (sync) and Workflow.resume_later(run_id, input = {}) (enqueued) continue a suspended run, feeding input in as #resume_input.\n\n\n\n\nThe basic loop\n\nclass DocumentApproval < Nexo::Workflow\n def call(payload)\n document = checkpoint(:fetch) { fetch_expensive(payload[:id]) } # paid for once\n\n # `resume_input` is {} on the first pass, so we pause; on resume the host\n # feeds { approved: true }, so we fall through and publish.\n suspend!(reason: \"awaiting approval\") unless resume_input[:approved]\n\n checkpoint(:publish) { publish!(document) }\n { done: true }\n end\nend\n\nrun = DocumentApproval.run(id: 42) # reaches suspend!, returns\nrun.status # => \"suspended\"\nrun.suspend_reason # => \"awaiting approval\" (AR store)\nrun.state[\"fetch\"] # => the fetched document (checkpoint persisted)\n\n# ...later, once a human approves — possibly in another process:\nresumed = DocumentApproval.resume(run.id, approved: true)\nresumed.status # => \"done\" (the :fetch block did NOT re-run)\n\n\nA host UI lists paused runs with the suspended scope and inspects them with the readers (Nexo ships no controllers/views — the UI is your app’s job):\n\nNexo::WorkflowRun.suspended # scope: all paused runs\nrun.suspended? # => true\nrun.suspend_reason # => \"awaiting approval\"\nrun.checkpoint_result(:fetch) # => the stored :fetch value, or nil\n\n\nFor a durable, cross-process resume from a background job, enqueue it — the job carries the run id plus the (json-safe) resume input; the payload still lives on the run:\n\n# The resume input is a positional Hash (queue: is the only keyword), so pass it\n# as { approved: true } — bare approved: true would bind as an unknown keyword.\nDocumentApproval.resume_later(run.id, { approved: true }, queue: :nexo)\n\n\nLive example\n\nThe full offline approval flow is runnable in the repo:\n\nruby -Ilib examples/approval_workflow.rb\n\n\n\n View examples/approval_workflow.rb on GitHub →\n\n\n\n\nParallel checkpoints — checkpoint_all\n\nWhen several checkpoints are independent (no step depends on another’s result), run them concurrently with checkpoint_all(name => callable, …) instead of a sequence of checkpoint calls. It fans the pending steps out through Nexo.concurrent — all in flight at once — and persists each step as it completes (not the batch as a whole), so a resume after a partial failure only re-runs the steps that never landed:\n\nclass BuildDashboard < Nexo::Workflow\n def call(payload)\n data = checkpoint_all(\n account: -> { fetch_account(payload[:id]) }, # these two run\n usage: -> { fetch_usage(payload[:id]) } # concurrently\n )\n { report: render(data[:account], data[:usage]) }\n end\nend\n\n\ncheckpoint_all returns a Hash keyed by the original names you passed (data[:account]), with values read back from state — the same shape whether a value came from this pass or a prior one. Each newly-completed step also surfaces a \"checkpoint\"-typed event on the run’s event log (data is the step name only, never the value — so a dashboard can show batch progress without the event log carrying large or sensitive results). Steps already present in state are skipped silently and emit nothing.\n\nBound the batch by how many keys you pass — there is no separate rate knob; every pending step goes in flight. Because it drives Nexo.concurrent, checkpoint_all needs the async gem only when something is actually pending — an all-persisted pass returns the prior values directly without touching concurrency. The same restrictions as checkpoint apply: values must be json-serializable, a step must not be named after a reserved state key (__suspend__/__approval__/__buffer_events__ — raises Nexo::Error before any step runs), and do not call suspend! inside a step (undefined — unsupported).\n\n\n Known trade-off: per-step persistence, not an atomic batch. checkpoint_all is not transactional. If step B raises after step A persisted, A stays in state, B is absent, the run goes \"failed\", and the exception propagates through the workflow’s normal failure path (Nexo.concurrent’s “first failure re-raises, the rest stop” — it is not rescued away). A subsequent execute of the same run re-submits only the still-missing names — A is skipped, B re-runs. Do not treat a batch as all-or-nothing.\n\n\n\n\nDurable agent approval — :approve\n\nThe example above suspends at an explicit suspend! the workflow author placed. The :approve mode adds the durable, cross-process sibling of :ask for the case where a run_agent-driven agent hits a permission gate mid-loop and you want that to pause the run for a human, not run unchecked and not block a worker. Declare the agent under the :approve mode:\n\nclass Scribe < Nexo::Agent\n model ENV.fetch(\"NEXO_MODEL\")\n sandbox :local\n permissions :approve # every gated capability needs a human decision\nend\n\nclass ApprovedWrite < Nexo::Workflow\n sandbox :local\n agent Scribe\n def call(_p) = { content: run_agent(\"Write 'hi' to notes.txt\").content }\nend\n\n\nThe loop is: :approve gate with no decision → Nexo::ApprovalRequired → run_agent suspends → host renders the pending call → resume(approved:) threads the decision back through the gate.\n\nrun = ApprovedWrite.run # agent reaches the write gate, run suspends\nrun.status # => \"suspended\"\nrun.state[\"__suspend__\"][\"reason\"] # => \"approval: notes.txt\"\nrun.state[\"__approval__\"] # => { \"capability\" => \"write\",\n # \"tool\" => \"notes.txt\", \"args\" => nil }\n# \"args\" carries the tool call arguments only for an MCP-tool approval; a sandbox\n# capability gate (write/shell/fetch/search) records \"args\" => nil — the pending\n# call is identified by \"capability\" + \"tool\".\n\n# ...a human approves — possibly in another process (resume_later for the AR store):\nresumed = ApprovedWrite.resume(run.id, approved: true)\nresumed.status # => \"done\" (the gate allowed the write)\n\n\n\n Nexo::ApprovalRequired is a signal, distinct from Permissions::Denied: Denied means “no, adapt” (tools rescue it into {error:}); ApprovalRequired means “pause and ask a human”, so tools must not rescue it — it propagates out of the tool loop and out of Agent#prompt, where run_agent catches it.\n Undecided ⇒ suspend, approved: false ⇒ deny. The default stays safe: an unresolved approval never silently allows, and a denial on resume makes the tool return {error:} (the model adapts) — the run still finishes \"done\", without the gated effect, never \"failed\".\n \n Scope which actions need approval with the same ask_when predicate as :ask (aliased approve_when: for readability) — unset means every gated action needs a decision; a falsey predicate auto-allows without one:\n\n Nexo::Permissions.new(mode: :approve,\n approve_when: ->(cap, detail) { cap == :write && detail.to_s.start_with?(\"/protected\") })\n \n \n Synchronous :ask is untouched. :ask (in-process on_ask) is still the right choice with a human at the keyboard during a synchronous run; :approve is its durable, cross-process sibling for run_later/resume_later.\n\n\nCaveats — read before relying on it\n\n\n Re-entry, not replay. On resume the agent re-drives #call from the top; a non-idempotent tool call before the approval gate re-runs on resume (agent tool calls generally aren’t checkpointable). Put approval gates early, or after the expensive work is already checkpointed by the workflow.\n One approval per suspend cycle, global decision. The {approved:} answers whichever gate the re-driven agent hits first. A second gate after an approved first one simply suspends again — the next resume decides it. There is no per-tool decision granularity in v1.\n Cross-process approval needs the ActiveRecord store + ActiveJob (like all durable resume). In-process resume works with the Memory store; a Memory run does not survive the process.\n Branch depends on upstream ruby_llm. This works because ruby_llm’s tool loop lets a tool execute exception propagate out of chat.ask (verified, 1.16.0). If a future ruby_llm swallows tool exceptions, tool-triggered approval would be constrained — a genuine upstream dependency, stated plainly.\n\n\nLive example\n\nThe live approval-agent flow is runnable in the repo:\n\nNEXO_LIVE=1 NEXO_MODEL=… ruby -Ilib examples/approval_agent.rb\n\n\n\n View examples/approval_agent.rb on GitHub →\n\n\nThe state column ships with fresh installs. Apps installed before this feature add it with an additive migration:\n\nrails g nexo:state\nrails db:migrate\n\n\n\n\nHonest resume semantics — read this before relying on resume\n\nResume re-enters #call from the top — Ruby has no transparent continuation capture, so this is not replay:\n\n\n Everything outside a checkpoint re-runs on resume. Only checkpoint-guarded work is skipped (its stored result is returned). Wrap every expensive step and every side effect in a checkpoint; the idempotency of the non-checkpointed code is your responsibility.\n A crash inside a checkpoint re-runs that checkpoint on resume (at-least-once for the in-flight step) — so a checkpoint’s side effect should tolerate being retried.\n Checkpoint values must be json-serializable — they round-trip the store exactly like result/events.\n Cross-process resume needs the ActiveRecord store. A run suspended under the in-memory store resumes only in-process (which is what the test suite exercises); a run that must survive the process needs the AR store with a shared database.\n Never suspend! inside a checkpoint block (undefined — unsupported), and never name a checkpoint \"__suspend__\" (reserved for the suspend metadata) or \"__approval__\" (reserved for the pending approval call) — both are keys Nexo stores in state.\n\n\nThere is no distinct \"resumed\" status: resume re-enters execute, so a host sees the existing suspended → running → done (or suspended again) transitions over the usual nexo.workflow.status notifications. The boot reconcile_interrupted! sweep leaves \"suspended\" runs untouched — an intentional pause is never mistaken for an orphaned \"running\" run.\n\n\n\nNext steps\n\n\n \n \n Workflows\n \n \n The run lifecycle these durability primitives build on.\n \n \n\n \n \n Rails\n \n \n Persist runs in a shared store for cross-process resume." }, { "id": "documentation-nexo-examples", "title": "Examples", "collection": { "label": "pages", "name": "Posts" }, "categories": "", "tags": "", "url": "/documentation/nexo/examples/", "content": "Each example in the Nexo repo is a small, runnable script. Two kinds:\n\n\n Offline — no model, no network, no API key. Run them as-is to see the primitive work.\n Live (NEXO_LIVE=1) — needs a real tool-calling model (NEXO_MODEL takes any ruby_llm-supported model id; nothing is provider-specific) and sometimes an external service (an MCP server, docker, an API key).\n\n\nRun everything from the repo root with ruby -Ilib examples/<name>.rb.\n\n\n\nOffline (start here)\n\n\n \n \n Example\n Shows\n \n \n \n \n artifact_from_template.rb\n Staging input files into a run’s sandbox + rendering a named artifact from a trusted ERB template\n \n \n approval_workflow.rb\n Durable human-in-the-loop: checkpoint + suspend! + resume\n \n \n\n\n\n\nLive — agents\n\n\n \n \n Example\n Shows\n Extra requirements\n \n \n \n \n code_reviewer.rb\n The minimal agent against a local Ollama model, with a skill and token accounting\n Ollama running locally\n \n \n chat_session.rb\n A continuing, addressable Nexo::Session that remembers prior turns\n —\n \n \n container_review.rb\n Agent tools running inside a locked-down OCI container\n docker (or Apple container)\n \n \n news_summary.rb\n Read-only web fetch scoped by fetch_allow\n —\n \n \n news_search.rb\n Host-injected search_backend + fetch\n a search backend you inject\n \n \n\n\n\n\nLive — MCP\n\n\n \n \n Example\n Shows\n Extra requirements\n \n \n \n \n mcp_filesystem.rb\n The MCP seam + permission gate with the official filesystem server — no credentials needed; start here for MCP\n npx\n \n \n inbox_digest.rb\n Gmail through a stdio MCP server + the email_triage skill, read tools only\n a Gmail MCP server + OAuth\n \n \n inbox_digest_http.rb\n The same digest over a hosted HTTP MCP server with a host-supplied OAuth bearer token\n a hosted Gmail MCP server\n \n \n inbox_digest_task.rb\n The digest as a Workflow Task: agent macro + run_agent + a named artifact\n same as inbox_digest.rb\n \n \n\n\n\n\nLive — workflows\n\n\n \n \n Example\n Shows\n Extra requirements\n \n \n \n \n approval_agent.rb\n The :approve permission mode bridged to a durable suspend/resume\n —\n \n \n\n\n\n\nSkills used by the examples\n\nThe skills/ directory holds the SKILL.md packages the examples reference — email_triage, news_summary, and ruby-code-review. The examples point Nexo.config.skills_path there; in a Rails host the default is app/skills.\n\n\n \n \n Skill\n Used by\n \n \n \n \n email_triage\n inbox_digest.rb, inbox_digest_http.rb, inbox_digest_task.rb\n \n \n news_summary\n news_summary.rb, news_search.rb\n \n \n ruby-code-review\n code_reviewer.rb\n \n \n\n\n\n\nRails walkthrough\n\nA host-side Rails walkthrough covers run_later, live progress, run helpers, and artifact access:\n\n\n View examples/rails_usage.md on GitHub →\n\n\n\n\nNext steps\n\n\n \n \n Getting started\n \n \n Install Nexo and build your first agent in five lines.\n \n \n\n \n \n GitHub Repository\n \n \n Source code, issues, and the full guide set in the repo." }, { "id": "documentation-nexo-getting-started", "title": "Getting started", "collection": { "label": "pages", "name": "Posts" }, "categories": "", "tags": "", "url": "/documentation/nexo/getting-started/", "content": "Install Nexo, configure the harness in one place, and build your first agent. Defaults are safe and provider-neutral — there is intentionally no hardcoded model.\n\n\n\nInstallation\n\nAdd to your Gemfile:\n\ngem \"nexo_ai\"\n\n\nOr install directly:\n\ngem install nexo_ai\n\n\nIn a Rails app, run the install generator to create the conventional layout and an initializer:\n\nrails g nexo:install\n\n\n create app/agents/.keep\n create app/workflows/.keep\n create app/skills/.keep\n create config/initializers/nexo.rb\n\n\nrequire \"nexo\" works in plain Ruby with no Rails loaded.\n\n\n\nConfiguration\n\nConfigure the harness in one place with Nexo.configure:\n\nNexo.configure do |config|\n config.default_model = ENV[\"NEXO_MODEL\"] # provider-neutral: no default\n config.default_sandbox = :virtual # :virtual | :local | :docker | :apple | a Hash | a Sandbox\n config.default_permissions = :read_only # :read_only | :auto | :ask | :approve\n config.skills_path = \"app/skills\"\n config.concurrency = :threaded # :threaded | :async (opt-in fiber offload)\n config.max_in_flight = 8 # Nexo.concurrent fan-out bound\n config.buffer_workflow_events = false # buffer + flush-once workflow events\nend\n\nNexo.config.default_sandbox # => :virtual\nNexo.config.default_permissions # => :read_only\nNexo.config.default_model # => nil unless set\n\n\nThere is deliberately no hardcoded model — you set NEXO_MODEL (or default_model) to any ruby_llm-supported model id.\n\n\n\nBuild an agent in five lines\n\nSubclass Nexo::Agent, declare the pieces with class macros, and call #prompt. No sandbox, permission, or tool object is wired by hand, and nothing is vendor-specific — the agent runs on any ruby_llm-supported model (set NEXO_MODEL, e.g. a local gemma3:12b via Ollama, or a hosted model):\n\nrequire \"nexo\"\n\nclass CodeReviewer < Nexo::Agent\n model ENV.fetch(\"NEXO_MODEL\") # any ruby_llm model — never a hardcoded vendor default\n sandbox :local\n permissions :read_only\n\n instructions \"You are a careful code reviewer. Read files and report issues. Do not write files.\"\nend\n\nCodeReviewer.new(cwd: \"/path/to/repo\").prompt(\"Review the auth module\")\n\n\nDefaults are safe: an agent with no sandbox/permissions declared gets the in-memory :virtual sandbox and :read_only permissions, so an untrusted model has zero host access until you explicitly opt in.\n\n\n Safe by default: agents start :virtual + :read_only — an untrusted model has zero host access until you explicitly opt in.\n\n\n\n\nUnregistered models — local tags, self-hosted, brand-new releases\n\nruby_llm normally validates a model id against its bundled models.json registry and infers the provider from it. A local Ollama tag (gemma3:12b), a self-hosted build, or a model newer than the installed registry isn’t listed there — so declare the provider explicitly and set assume_model_exists to skip the registry lookup:\n\nclass LocalReviewer < Nexo::Agent\n model \"gemma3:12b\"\n provider :ollama # required once the registry lookup is skipped\n assume_model_exists true # opt out of models.json validation\n\n instructions \"You are a careful code reviewer.\"\nend\n\n\nBoth are class macros with the same reader/writer convention as model. provider is passed straight through to RubyLLM.chat; assume_model_exists defaults to false (registry validation on). Setting assume_model_exists without a provider raises Nexo::ConfigurationError — ruby_llm can’t infer a provider once the lookup is skipped.\n\n\n\nWhere to next\n\n\n Sandboxes — the four execution environments and hardened defaults.\n Permissions — the capability gate and the four modes.\n Examples — runnable scripts including a local-Ollama code reviewer.\n\n\n\n\nNext steps\n\n\n \n \n Sandboxes\n \n \n Choose where an agent's tools act — Virtual, Local, Container, or Remote.\n \n \n\n \n \n Permissions\n \n \n Control what those tools may do, read-only by default." }, { "id": "documentation-nexo", "title": "Nexo", "collection": { "label": "pages", "name": "Posts" }, "categories": "", "tags": "", "url": "/documentation/nexo/", "content": "Agent = Model + Harness. Nexo is the connective tissue linking RubyLLM to tools, sandboxes, skills, and runs.\n\n\nA model alone forgets everything the moment a response ends. The harness is everything else. Nexo gives the RubyLLM ecosystem one cohesive front door with safe defaults — build a working agent in five lines without wiring anything.\n\n\n\nCompose, don’t reimplement\n\nNexo does not rebuild skill loading, the tool-call loop, MCP, or structured output — those already live in the RubyLLM ecosystem (ruby_llm core, ruby_llm-skills, ruby_llm-mcp, ruby_llm-schema). Nexo composes them behind one front door and adds only the two pieces the ecosystem is missing:\n\n\n Sandbox + Permissions seam — a pluggable execution environment (virtual / local / remote / container) with explicit authorization gating. Default: :virtual + :read_only.\n WorkflowRun lifecycle — a finite-job primitive (runId, status, payload, result, inspectable event log) that nothing else in the ecosystem provides cleanly.\n\n\n\n\nBuild an agent in five lines\n\nSubclass Nexo::Agent, declare the pieces with class macros, and call #prompt. No sandbox, permission, or tool object is wired by hand, and nothing is vendor-specific — the agent runs on any ruby_llm-supported model (set NEXO_MODEL, e.g. a local gemma3:12b via Ollama, or a hosted model):\n\nrequire \"nexo\"\n\nclass CodeReviewer < Nexo::Agent\n model ENV.fetch(\"NEXO_MODEL\") # any ruby_llm model — never a hardcoded vendor default\n sandbox :local\n permissions :read_only\n\n instructions \"You are a careful code reviewer. Read files and report issues. Do not write files.\"\nend\n\nCodeReviewer.new(cwd: \"/path/to/repo\").prompt(\"Review the auth module\")\n\n\n\n Safe by default: agents start :virtual + :read_only — an untrusted model has zero host access until you explicitly opt in.\n\n\n\n\nInstallation\n\nAdd to your Gemfile:\n\ngem \"nexo_ai\"\n\n\nOr install directly:\n\ngem install nexo_ai\n\n\nIn a Rails app, run the install generator to create the conventional layout and an initializer:\n\nrails g nexo:install\n\n\n create app/agents/.keep\n create app/workflows/.keep\n create app/skills/.keep\n create config/initializers/nexo.rb\n\n\n\n\nThe guides\n\n\n \n \n Guide\n What’s inside\n \n \n \n \n Getting started\n install, configuration, first agent, unregistered/local models\n \n \n Sandboxes\n virtual / local / remote / container + hardened defaults\n \n \n Permissions\n modes, the gate, the MCP gate, :ask, :approve\n \n \n Tools\n ReadFile / WriteFile / Shell / Glob\n \n \n Loops\n RubyLLM vs AgentSDK, the turn-cap caveat\n \n \n Workflows\n lifecycle, staging, artifacts, run_agent, tasks & actions\n \n \n Durable workflows\n checkpoint / suspend / resume\n \n \n Skills\n SKILL.md packages, gated tools\n \n \n MCP\n mcp macro, fail-closed gate, transports\n \n \n Web\n fetch tool + SSRF guard, search tool + injected backend\n \n \n Sessions\n continuing, addressable memory\n \n \n Rails\n engine, run_later, broadcasting, generators\n \n \n Concurrency\n opt-in async, buffered emit, fiber servers\n \n \n Examples\n runnable scripts — offline and live\n \n \n\n\n\n\nRequirements\n\n\n Ruby 3.3+\n ruby_llm >= 1.16\n ruby_llm-skills — optional, only when you use the skills macro\n ruby_llm-mcp — optional, only when you attach an MCP server with the mcp macro\n ruby_llm-agent_sdk — optional, only when you choose the Anthropic-oriented Loops::AgentSDK backend\n\n\n\n\nStatus\n\nEarly development. The API is not stable. Nexo ships safe defaults and honest caveats — every escalation is an explicit opt-in, and every reduced guarantee is documented rather than silently dropped.\n\n\n\nNext steps\n\n\n \n \n Get started\n \n \n Install Nexo, configure the harness, and build your first agent.\n \n \n\n \n \n Examples\n \n \n Runnable scripts — offline primitives and live agents, MCP, and workflows.\n \n \n\n \n \n GitHub Repository\n \n \n Source code, issues, and the full guide set in the repo.\n \n \n\n \n \n RubyGems\n \n \n Install the latest version from RubyGems." }, { "id": "documentation-nexo-loops", "title": "Loops", "collection": { "label": "pages", "name": "Posts" }, "categories": "", "tags": "", "url": "/documentation/nexo/loops/", "content": "The loop is the engine that drives one prompt to completion. Swapping it is constructor injection (loop:) — the agent class never changes. Two backends ship.\n\n\n\nThe two backends\n\n\n \n \n  \n Loops::RubyLLM (default)\n Loops::AgentSDK (opt-in)\n \n \n \n \n Provider neutral\n Yes — any ruby_llm model\n No — Anthropic-oriented\n \n \n Tool source\n your sandbox-backed tools\n the SDK’s own built-in/host tools\n \n \n Turn cap\n observability only (see caveat)\n native max_turns hard cap\n \n \n Execution location\n your sandbox (virtual/local/remote)\n the host process\n \n \n\n\nThe whole point: same agent code, swapped backends. Both examples are model-agnostic (ENV.fetch(\"NEXO_MODEL\") — never a hardcoded \"claude-…\"):\n\n# Claude fast path — AgentSDK's own loop + host tools + native max_turns\nclaude = Nexo::Agent.new(\n model: ENV.fetch(\"NEXO_MODEL\"),\n sandbox: Nexo::Sandboxes::Local.new(cwd: \"/srv/checkout\"),\n permissions: Nexo::Permissions.new(mode: :auto),\n loop: Nexo::Loops::AgentSDK.new\n)\n\n# Any-provider path — your sandbox, your tools, human-in-the-loop\ngpt = Nexo::Agent.new(\n model: ENV.fetch(\"NEXO_MODEL\"), # gpt-5.5, gemini, gemma3:12b via Ollama…\n sandbox: Nexo::Sandboxes::Remote.new(client: my_container_client),\n permissions: Nexo::Permissions.new(mode: :ask, on_ask: ->(cap, detail) {\n SlackApproval.request!(capability: cap, detail: detail)\n }),\n loop: Nexo::Loops::RubyLLM.new\n)\n\n\nLoops::AgentSDK wraps RubyLLM::AgentSDK.query and requires the optional ruby_llm-agent_sdk gem (lazy require; a clear Nexo::MissingDependencyError if it’s absent). It maps Nexo’s permission modes onto the SDK’s own vocabulary:\n\n\n \n \n Nexo mode\n AgentSDK permission_mode\n \n \n \n \n :read_only\n :default\n \n \n :auto\n :bypass_permissions\n \n \n :ask\n :default (human gating stays in Nexo’s own on_ask path, not delegated to the SDK)\n \n \n :approve\n :default (durable approval stays in Nexo’s own gate; any unmapped mode also falls back to :default)\n \n \n\n\n\n\nThe turn-cap caveat — read before running untrusted/expensive workloads\n\nruby_llm runs the whole tool loop inside ask, so Loops::RubyLLM has no clean public hard “stop after N turns” halt — before_tool_call gives turn-count observability, not a hard stop. (Confirmed: ruby_llm 1.16.0’s Chat exposes no public max-turns/max-iterations setting.) Your three real options:\n\n\n (a) use Loops::AgentSDK (native max_turns) for untrusted/expensive workloads;\n (b) have a tool return { error: \"turn limit reached, stop and summarize\" } once a turn counter trips;\n (c) check whether the installed ruby_llm exposes a max-iterations config (in 1.16.0 it does not).\n\n\n\n Do not ship Loops::RubyLLM for untrusted workloads claiming a hard cap that isn’t proven.\n\n\n\n\nVerified vs assumed\n\nBuilt against ruby_llm 1.16 and ruby_llm-test 0.2. The tool body method is #execute, tools attach with chat.with_tools(*instances), and instructions set with chat.with_instructions. Open3.capture3 has no timeout: keyword on the target Ruby, so Local#shell bounds the command with Timeout.timeout. These may differ on other ruby_llm versions.\n\nLoops::RubyLLM’s turn-count observability uses RubyLLM::Chat#before_tool_call / #after_tool_result, confirmed present on ruby_llm 1.16.0 and guarded with respond_to? so a version lacking them degrades to no observability rather than crashing.\n\nLoops::AgentSDK targets RubyLLM::AgentSDK.query; ruby_llm-agent_sdk is not a dependency of this release, so that signature is assumed (per the gem’s README) and verified-on-install — confirm it the moment you add the gem.\n\n\n\nLive smoke (optional)\n\nThe core suite is fully offline and deterministic (models stubbed with ruby_llm-test). A real end-to-end check is opt-in and env-gated — small local models like Gemma have weak tool-calling, so it may be flaky and is never a gating test:\n\nollama serve &\nNEXO_LIVE=1 NEXO_MODEL=gemma3:12b bundle exec rake test TEST=test/live_smoke_test.rb\n\n\nIf Gemma’s tool-calling proves too weak, point NEXO_MODEL at a stronger model — the gem stays provider-neutral; only the smoke target changes.\n\n\n\nNext steps\n\n\n \n \n Sessions\n \n \n Give an agent memory that persists across invocations.\n \n \n\n \n \n Skills\n \n \n Teach the model how you want a task done with a SKILL.md package." }, { "id": "documentation-nexo-mcp", "title": "MCP", "collection": { "label": "pages", "name": "Posts" }, "categories": "", "tags": "", "url": "/documentation/nexo/mcp/", "content": "An MCP server exposes tools to a model over the Model Context Protocol — Gmail, a filesystem, a fetch endpoint, Drive, and so on. Nexo does not implement MCP; it composes the ruby_llm-mcp gem so you attach one or more servers with a single mcp macro and no client wiring. Because a server is reached through the protocol (never a vendor SDK), the behavior is identical on Anthropic, a local model, or anything else ruby_llm supports.\n\n\n\nAttach servers with one macro\n\nrequire \"nexo\"\n\nclass InboxDigest < Nexo::Agent\n model ENV.fetch(\"NEXO_MODEL\") # any ruby_llm model — never a hardcoded vendor default\n permissions :read_only\n mcp :gmail, transport: :stdio, command: \"npx\", args: %w[-y @modelcontextprotocol/server-gmail]\n mcp :fs, transport: :stdio, command: \"npx\", args: %w[-y @modelcontextprotocol/server-filesystem /data]\n mcp :fetch, transport: :sse, url: \"http://localhost:8080/sse\"\n mcp_allow %w[search_threads get_thread]\nend\n\n\nEach mcp line accumulates a server declaration. name and transport map onto the client’s name:/transport_type:; every other keyword is passed through verbatim as the server’s config: — command:/args: for :stdio, url: for :sse. The server’s tools are attached to the chat after the sandbox tools and skills, and fire the same before_tool_call/after_tool_result observability callbacks, so MCP calls appear in a run’s event log automatically.\n\n\n\nEvery MCP tool call is gated — and fails closed\n\nMCP tools obey a second permission axis, separate from the sandbox capability axis, because an MCP tool executes inside the server, outside the sandbox. mcp_allow is the exact-match allow-list threaded into the agent’s permissions:\n\n\n \n \n Mode\n MCP tool behavior\n \n \n \n \n :read_only (default)\n allow only tool names listed in mcp_allow; everything else is denied\n \n \n :ask\n call on_ask.call(:mcp, {tool:, args:}); a truthy return allows, else deny\n \n \n :approve\n names in mcp_allow are pre-approved; any other tool needs a human decision — undecided suspends the run (Nexo::ApprovalRequired), approved: true allows, approved: false denies (the durable sibling of :ask)\n \n \n :auto\n allow every MCP tool\n \n \n\n\nmcp_allow defaults to [], so attaching a powerful server under :read_only with no allow-list denies every tool — a misconfigured agent fails closed, not open. A denied call returns { error: … } to the model (recoverable) and never raises into the loop — identical to the sandbox tools. Escalation (:auto, a populated mcp_allow, or :ask with a real on_ask) is always explicit in your code. Matching is exact tool-name only — no globs or regexes.\n\n\n Safe by default: attaching an MCP server adds no permission surface. The unchanged gate denies everything under :read_only until the exact name is in mcp_allow (default [] ⇒ deny-all).\n\n\nTwo caveats — read before attaching a server\n\n\n MCP tool effects are not sandboxed. The gate covers the authority to invoke a tool; the tool then runs in the MCP server, outside Nexo’s sandbox. Nexo cannot constrain what that server does with a call it is authorized to make — attaching a write server and allowing a write tool means real writes happen. Gate deliberately, and prefer :read_only with a tight mcp_allow.\n \n Connection lifecycle. Clients are built once and memoized on the agent instance, reused across prompts. A long-lived agent holding stdio/SSE servers should call Agent#close when done to tear the connections down:\n\n agent = InboxDigest.new\nagent.prompt(\"Summarize invoices from this week\")\nagent.prompt(\"Any follow-ups needed?\") # reuses the same live MCP connections\nagent.close # stops every attached server\n \n \n\n\n\n\nHTTP-family servers + an OAuth token: provider\n\nBeyond :stdio, Nexo attaches a server over any HTTP-family transport ruby_llm-mcp supports — transport: :http, :sse, or :streamable. For an OAuth-authenticated hosted server (Gmail, Drive, …) add a token: — a static bearer String, or a callable re-read close to connection time. Nexo resolves it and injects an Authorization: Bearer <token> header per connection:\n\nclass InboxTriageHTTP < Nexo::Agent\n model ENV.fetch(\"NEXO_MODEL\")\n permissions :read_only\n\n # Hosted Gmail MCP server over HTTP; the host supplies the OAuth access token.\n mcp :gmail,\n transport: :http,\n url: ENV.fetch(\"GMAIL_MCP_URL\"),\n token: -> { Current.user.gmail_access_token } # re-read at connection time\n\n # READ tools only — the unchanged gate denies send/trash/modify.\n mcp_allow %w[search_threads get_thread list_messages get_message list_labels]\nend\n\n\nA static token (token: ENV.fetch(\"GMAIL_TOKEN\")) is equally valid. Under the hood Nexo strips token: and hands off:\n\nRubyLLM::MCP.client(\n name: \"gmail\", transport_type: :http,\n config: { url: \"https://…\", headers: { \"Authorization\" => \"Bearer <resolved>\" } }\n)\n\n\nAny other headers: you pass are preserved; Nexo’s Authorization wins. With no token:, config: passes through byte-for-byte (no headers key) — the :stdio path is untouched.\n\n\n Nexo does not own the OAuth flow. It performs no authorization-code exchange, no token refresh, and keeps no token store — that is your app or an OAuth library. Nexo’s only job is to call the provider, inject the header, and hand off. The token is never logged, persisted, placed in a URL/query string, or emitted in an event.\n\n\nRefresh / reconnect caveat\n\nruby_llm-mcp’s HTTP-family transports snapshot the headers hash at construction — there is no per-request header callback for a plain headers Hash. A callable token: is therefore resolved once, when the client is built, and the client is memoized on the agent instance across prompts. So when a token rotates, tear the connection down and reconnect to pick up the new value:\n\nagent.close # stops the memoized MCP client\nagent.prompt(\"…\") # a fresh prompt rebuilds the client → token: re-resolved\n\n\nThe gate is unchanged — an HTTP OAuth server’s tools are gated exactly like :stdio tools. Attaching an authenticated server adds no permission surface.\n\nTwo honest caveats — read before attaching a token\n\n\n Refresh may require a reconnect. Because headers are construction-only, a rotated token needs agent.close + a fresh prompt, not just a new proc return. A static token stays constant for the client’s life.\n The token is a live credential. Even gated, an authorized MCP call runs its effect server-side — a leaked bearer is a real compromise. Nexo keeps it out of logs, events, persisted WorkflowRun records, and URLs; your host code must do the same. Nexo does not police ruby_llm-mcp’s own internal logging of headers — that boundary is yours.\n\n\n\n\nAn optional dependency\n\nruby_llm-mcp is an optional dependency — required lazily only when you attach a server. Without it installed, require \"nexo\" still loads; building a server raises a clear Nexo::MissingDependencyError telling you to add gem \"ruby_llm-mcp\".\n\n\n\nLive example — start here for MCP\n\nA no-auth-required example uses the official filesystem MCP server (npx, no credentials) so you can watch the safe-by-default gate in action end to end: read tools on the mcp_allow list are allowed; write_file is not on the list and the gate returns { error: ... } (denied).\n\nNEXO_LIVE=1 NEXO_MODEL=gemma3:12b ruby -Ilib examples/mcp_filesystem.rb /tmp\n\n\n\n View examples/mcp_filesystem.rb on GitHub →\n\n\n\n\nNext steps\n\n\n \n \n Web\n \n \n Host-process fetch and search, gated by a capability and allow-list.\n \n \n\n \n \n Permissions\n \n \n How the mcp_allow axis composes with tool capabilities." }, { "id": "documentation-nexo-permissions", "title": "Permissions", "collection": { "label": "pages", "name": "Posts" }, "categories": "", "tags": "", "url": "/documentation/nexo/permissions/", "content": "The permission mode is what an agent’s tools may do. Two seams compose the execution environment: the sandbox is where tools act (Sandboxes); the permission mode is what they may do.\n\n\n Safe by default: agents start :read_only — read and glob are auto-allowed; every write, shell, fetch, and search is denied until you explicitly opt in. A denied capability returns { error: ... } and the agent loop continues — it does not raise. A path that escapes the workspace raises SecurityError; an agent built with no resolvable model raises Nexo::ConfigurationError.\n\n\n\n\nThe four modes\n\n\n \n \n Mode\n :read/:glob\n :write/:shell/:fetch/:search\n When to use\n \n \n \n \n :read_only (default)\n Yes\n No {error}\n Untrusted models, the safe baseline\n \n \n :auto\n Yes\n Yes\n Fully trusted local dev/CI\n \n \n :ask\n Yes\n per on_ask\n A human at the keyboard during a synchronous run\n \n \n :approve\n Yes\n per decision\n Durable, cross-process human-in-the-loop (see Durable workflows)\n \n \n\n\n:read/:glob are auto-allowed under every mode (they sit in the default allow list), so :ask/:approve never prompt for them — only :write/:shell/:fetch/:search reach the gate.\n\nYou can also grant individual capabilities without changing the mode — Permissions.new(mode: :read_only, allow: %i[read glob fetch]) lets fetch through while keeping write/shell denied. This is how the Web tools are typically enabled.\n\n\n\nThe gate\n\nA denied capability returns { error: ... } to the model (recoverable) and never raises into the loop — identical to a sandbox tool failure. The gate is a first-class capability check, not a coarse mode switch, so a :read_only agent that never calls fetch_allow gets no fetch tool at all (see Web).\n\nEscalation is always explicit in your code: :auto, an allow: list, a populated mcp_allow, or :ask with a real on_ask.\n\n\n\nThe MCP gate — a second, fail-closed axis\n\nMCP tools obey a second permission axis, separate from the sandbox capability axis, because an MCP tool executes inside the server, outside the sandbox. mcp_allow is the exact-match allow-list:\n\n\n \n \n Mode\n MCP tool behavior\n \n \n \n \n :read_only (default)\n allow only tool names listed in mcp_allow; everything else denied\n \n \n :ask\n call on_ask.call(:mcp, {tool:, args:}); truthy allows, else deny\n \n \n :approve\n names in mcp_allow are pre-approved; any other tool needs a human decision — undecided suspends the run, approved: true allows, approved: false denies\n \n \n :auto\n allow every MCP tool\n \n \n\n\nmcp_allow defaults to [], so attaching a powerful server under :read_only with no allow-list denies every tool — a misconfigured agent fails closed, not open. Matching is exact tool-name only — no globs or regexes. See MCP for the full detail.\n\n\n\nHuman-gated writes (:ask)\n\n:ask mode defers every write/shell action to your callback. Build a Permissions with an on_ask hook and pass it in:\n\ngate = Nexo::Permissions.new(mode: :ask, on_ask: ->(cap, detail) {\n $stdout.print(\"Allow #{cap} #{detail}? [y/N] \"); $stdin.gets.strip == \"y\"\n})\n\nclass Editor < Nexo::Agent\n model ENV.fetch(\"NEXO_MODEL\")\n sandbox :local\nend\n\nEditor.new(cwd: \".\", permissions: gate).prompt(\"Fix the typo in README.md\")\n\n\nThe bare :ask symbol resolves to Permissions.new(mode: :ask) with no callback, so writes/shell are denied — pass a pre-built Permissions with on_ask for a real gate.\n\nScope which actions prompt — ask_when\n\nUnder :ask, ask_when scopes which actions actually prompt a human. When the predicate returns falsey the action is auto-allowed without calling on_ask; truthy (or when ask_when is unset) falls through to on_ask exactly as before. Unset = ask for everything. It only ever narrows what is auto-allowed — it never widens authority.\n\n# Only prompt for writes under /protected; auto-allow everything else.\nperms = Nexo::Permissions.new(\n mode: :ask,\n on_ask: ->(cap, detail) { ask_the_human(cap, detail) },\n ask_when: ->(cap, detail) { cap == :write && detail.to_s.start_with?(\"/protected\") }\n)\n\n\n\n\nDurable approval (:approve)\n\n:approve is the durable, cross-process sibling of :ask for the case where a run_agent-driven agent hits a permission gate mid-loop and you want that to pause the run for a human, not run unchecked and not block a worker.\n\nThe loop is: :approve gate with no decision → Nexo::ApprovalRequired → run_agent suspends → host renders the pending call → resume(approved:) threads the decision back through the gate.\n\n\n Nexo::ApprovalRequired is a signal, distinct from Permissions::Denied: Denied means “no, adapt” (tools rescue it into {error:}); ApprovalRequired means “pause and ask a human”, so tools must not rescue it.\n Undecided ⇒ suspend, approved: false ⇒ deny. An unresolved approval never silently allows; a denial on resume makes the tool return {error:} (the model adapts) — the run still finishes \"done\", without the gated effect, never \"failed\".\n Scope which actions need approval with the same ask_when predicate (aliased approve_when: for readability).\n\n\nNexo::Permissions.new(mode: :approve,\n approve_when: ->(cap, detail) { cap == :write && detail.to_s.start_with?(\"/protected\") })\n\n\n:ask (in-process on_ask) is the right choice with a human at the keyboard during a synchronous run; :approve is its durable, cross-process sibling for run_later/resume_later. See Durable workflows for the suspend/resume mechanics and the honest caveats (re-entry not replay, one approval per suspend cycle, needs the ActiveRecord store + ActiveJob for cross-process).\n\nLive example\n\nThe :approve mode bridged to a durable suspend/resume is exercised by a live example in the repo:\n\nNEXO_LIVE=1 NEXO_MODEL=… ruby -Ilib examples/approval_agent.rb\n\n\n\n View examples/approval_agent.rb on GitHub →\n\n\n\n\nNext steps\n\n\n \n \n Sandboxes\n \n \n The other safety axis — where an agent's tools act.\n \n \n\n \n \n Durable workflows\n \n \n Take the :approve gate cross-process with suspend and resume." }, { "id": "documentation-nexo-rails", "title": "Rails", "collection": { "label": "pages", "name": "Posts" }, "categories": "", "tags": "", "url": "/documentation/nexo/rails/", "content": "Rails wiring: run a Workflow asynchronously on your existing ActiveJob adapter, broadcast its events live, and query runs and artifacts from your own controllers. Nexo ships no queue, no scheduler, no cable backend, and no UI — only the primitives plus one overridable partial.\n\nThe install generator (rails g nexo:install) is covered in Getting started; the per-feature generators (rails g nexo:workflows, nexo:artifacts, nexo:state, nexo:skill) live with their topics in Workflows, Durable workflows, and Skills.\n\n\n\nInstall the store (needed for cross-process run_later)\n\nrun_later enqueues a job that carries only the run id; the worker looks the run up in the store. For a worker in another process to find it, use the ActiveRecord store:\n\nrails g nexo:install # config/initializers/nexo.rb\nrails g nexo:workflows # the nexo_workflow_runs migration\nrails db:migrate\n\n\nIn config/initializers/nexo.rb, opt into the pieces you want:\n\nNexo.configure do |config|\n config.default_model = ENV[\"NEXO_MODEL\"]\n config.job_queue = :nexo # route workflow jobs to a dedicated queue (optional)\n config.broadcast_events = true # opt-in Turbo mirror (requires turbo-rails)\nend\n\n\n\n\nBackground execution — run_later\n\nMyWorkflow.run_later(payload) enqueues the run on your existing ActiveJob adapter and hands back the run immediately (status \"queued\"), so a controller can return while the work happens in the background. The job carries only the run id — the payload lives on the run record, so no arguments (and no secrets) travel through the queue. When the worker picks it up, it reconstitutes the workflow and calls the same execute the sync path uses, so an async run reaches the identical done/failed lifecycle, event log, and status notifications:\n\nclass GenerateReport < Nexo::Workflow\n def call(payload) = { url: build_report(payload[:account_id]) }\nend\n\nrun = GenerateReport.run_later(account_id: 42) # returns at once\nrun.status # => \"queued\"\n# ...the worker runs it in the background; later:\nNexo::RunStore.default.find(run.id).status # => \"done\"\n\n\nRoute jobs to a dedicated queue per call or globally:\n\nGenerateReport.run_later(account_id: 42, queue: :nexo) # per-call\nNexo.configure { |c| c.job_queue = :nexo } # or a global default\n\n\nScheduling a future run or resume\n\nrun_later and resume_later accept wait: (a duration) or wait_until: (an absolute time), forwarded straight to the installed ActiveJob’s own .set(...) scheduler — Nexo adds no scheduler of its own. Use them to defer an initial enqueue (“send this digest at 9am”) or to let a suspended run wake itself on a timer, symmetrically:\n\n# Defer the initial enqueue until tomorrow morning.\nDailyDigest.run_later({account_id: 42}, wait_until: Date.tomorrow.noon)\n\n# Let a suspended run wake itself up in an hour (no separate scheduled job).\nMyWorkflow.resume_later(run.id, {reminder: true}, wait: 1.hour)\n\n\nThe run’s status is unchanged — a scheduled run_later is still \"queued\" (no \"scheduled\" status is invented), and a scheduled resume_later leaves the run \"suspended\" until the job fires. Passing both wait: and wait_until: in one call raises ArgumentError (checked before any run is created or job enqueued). With neither given, the enqueue is byte-for-byte the immediate one above.\n\n\n wait:/wait_until:/queue: are scheduling options, not payload. A bare-keyword call consumes them as options: run_later(wait: 60) schedules the job 60 seconds out and leaves the payload {} — it does not store \"wait\" => 60 as data. A payload that legitimately needs a key named \"wait\" must be passed as an explicit positional Hash: run_later({wait: \"value\"}).\n\n\nThis is still “no scheduler, no cron” — wait:/wait_until: schedule a single future run/resume via ActiveJob; recurring schedules stay the host’s.\n\nNo queue, no scheduler — and the honest caveats\n\nNexo ships no queue and no scheduler — ActiveJob uses whatever adapter your app configured (Sidekiq, GoodJob, Solid Queue, …), and scheduling (cron / GoodJob / whenever) stays the host’s. Without ActiveJob, run_later raises Nexo::MissingDependencyError — use run for synchronous execution.\n\n\n Needs a shared store. For a worker in another process to find the run, use the ActiveRecord store with a real adapter — the run must live in the database, not in a per-process memory store. The in-memory store only works under the :inline/:test adapters, where the job runs in-process on enqueue.\n\n No automatic crash recovery / no automatic retries. A crashed or retried job re-runs #call from scratch — Nexo adds no retry_on (configure retries in your host job if you want them). Pair with reconcile_interrupted! (Workflows) to sweep runs orphaned in \"running\". For an intentional pause-and-continue, see Durable workflows — checkpoint skips already-paid-for work when a run resumes.\n\n\n\n\nLive progress — notifications and opt-in Turbo\n\nEvery run broadcasts as it happens over ActiveSupport::Notifications, decoupled from persistence (events still buffer/persist separately). Two notifications fire (a no-op with no ActiveSupport, so the plain-Ruby core stays Rails-free):\n\n\n nexo.workflow.event — one per emit, payload { run_id:, event: } (the event is the string-keyed {\"type\" =>, \"data\" =>, \"at\" =>} hash). Fires live, even when event persistence is buffered.\n nexo.workflow.status — on each status transition, payload { run_id:, status: }.\n\n\nThe payloads carry only what emit/the run already hold — no payload or credential dumps. Subscribe for logging, metrics, or your own UI:\n\nActiveSupport::Notifications.subscribe(\"nexo.workflow.event\") do |*, payload|\n Rails.logger.info(\"[run #{payload[:run_id]}] #{payload[:event][\"type\"]}\")\nend\n\n\nOpt-in Turbo mirror\n\nSet config.broadcast_events = true (and have turbo-rails present) and the engine subscribes Nexo::TurboBroadcaster, which appends each event to a per-run Turbo stream, rendering the overridable partial app/views/nexo/_event.html.erb. To show live progress, add to your own page (Nexo ships no controllers, routes, or dashboard — the host owns all HTTP + UI):\n\n<%= turbo_stream_from \"nexo_run_#{@run.id}\" %>\n<div id=\"nexo_run_<%= @run.id %>_events\">\n <%# appended events land here %>\n</div>\n\n\nOverride the appearance by defining your own app/views/nexo/_event.html.erb in the host app — it takes precedence over the engine’s default.\n\nNexo.configure { |c| c.broadcast_events = true } # opt in; requires turbo-rails\n\n\n\n Broadcast reachability. Broadcasts fire from wherever the run executes — under run_later, that’s the worker process. The cable backend (AnyCable, Solid Cable, Redis) must therefore be reachable from your workers, not just your web dynos. Nexo ships no cable backend — broadcasting composes whatever the host configured. Without turbo-rails, broadcast_events is a harmless no-op: the notifications still fire, so you can subscribe to them yourself.\n\n\n\n\nRun helpers for a host UI\n\nNexo::WorkflowRun exposes query helpers so a host can build its own runs UI without Nexo dictating controllers or views:\n\nNexo::WorkflowRun::STATUSES # => %w[pending queued running done failed interrupted suspended]\n\nNexo::WorkflowRun.queued # scope: status \"queued\"\nNexo::WorkflowRun.running # scope: status \"running\"\nNexo::WorkflowRun.finished # scope: status \"done\" or \"failed\"\nNexo::WorkflowRun.suspended # scope: status \"suspended\" (paused, awaiting resume)\n\nrun.queued? run.running? run.done? run.failed? run.suspended? # predicates\n\n# Artifact access — content only; serving files stays your\n# controller's job (Nexo ships no artifact routes/controllers):\nrun.artifact(\"digest.md\") # => {\"name\" =>, \"content\" =>, \"at\" =>} or nil\nrun.artifact_content(\"digest.md\") # => \"…the body…\" or nil\n\n\nArtifact access is content only; serving files stays your controller’s job — Nexo ships no artifact routes or controllers.\n\n\n\nWalkthrough\n\nA controller + Turbo-page host-side walkthrough is in the repo — install the store, define a workflow, enqueue it from a controller, and render live progress:\n\n\n View examples/rails_usage.md on GitHub →\n\n\nA live example also wraps an MCP-backed agent in a workflow and captures the digest as an artifact (Task + run_agent):\n\n\n View examples/inbox_digest_task.rb on GitHub →\n\n\n\n\nNext steps\n\n\n \n \n Workflows\n \n \n The run primitive run_later executes in the background.\n \n \n\n \n \n Durable workflows\n \n \n Pause and continue a run across processes." }, { "id": "documentation-nexo-sandboxes", "title": "Sandboxes", "collection": { "label": "pages", "name": "Posts" }, "categories": "", "tags": "", "url": "/documentation/nexo/sandboxes/", "content": "The sandbox is where an agent’s tools act. Pick in-memory Virtual, host-backed Local, a throwaway Container, or a Remote you inject. Two seams compose the execution environment: the sandbox is where tools act; the permission mode is what they may do (Permissions).\n\nA denied capability returns { error: ... } and the agent loop continues — it does not raise. A path that escapes the workspace raises SecurityError; an agent built with no resolvable model raises Nexo::ConfigurationError.\n\n\n\nThe four sandboxes\n\n\n \n \n Sandbox\n What it is\n :shell\n Best for\n \n \n \n \n Virtual (default)\n In-memory, zero host access\n raises NotImplementedError (intentional)\n Reading staged data, pure-Ruby work\n \n \n Local\n Host filesystem + shell, guarded to cwd\n Yes (narrowed ENV)\n Trusted dev/CI\n \n \n Container\n Throwaway OCI container via docker or Apple container CLI\n Yes (in container)\n Model-driven work, untrusted models\n \n \n Remote\n A remote container you inject (E2B / Daytona / Modal / Docker / your own)\n Yes (via injected client)\n Cloud sandboxes, scale-out\n \n \n\n\n\n Safe by default: agents start :virtual — escalating to :local, :container, or :remote is always an explicit choice in your code. The default never widens host access.\n\n\n\n Virtual (default) — in-memory, zero host access. #shell raises NotImplementedError on purpose: in-memory means no command execution. That is the safety property, not a gap.\n Local — host filesystem + shell, for trusted dev/CI. Two guards: every path is expanded against cwd and must stay inside it (else SecurityError), and the shell sees only PATH, HOME, LANG (plus explicit env: additions) — never the full process environment.\n Container — run the tools inside a throwaway local container via the docker (default) or Apple container CLI. Shell-out only, no client gem. Hardened by default (no network, dropped caps, read-only rootfs + ephemeral scratch, read-only host binds); every hardening is an explicit opt-out. See Container sandbox below.\n Remote — run the tools inside a remote container by injecting a client. Escalating to :remote is always an explicit choice — the default stays :virtual.\n\n\n\n\nCapability matrix\n\nTools::Fetch and Tools::WebSearch (:fetch / :search) run in the host process (stdlib net/http / a host-injected backend), so no sandbox constrains them — not even a --network none container. They are bounded only by the capability gate plus fetch_allow / the injected backend. See Web.\n\n\n \n \n  \n :read\n :glob\n :write\n :shell\n :fetch\n :search\n \n \n \n \n Virtual sandbox\n Yes\n Yes\n Yes (in-memory)\n No NotImplementedError→{error}\n Yes †\n Yes †\n \n \n Local sandbox\n Yes (guarded)\n Yes\n Yes (guarded)\n Yes (narrowed ENV)\n Yes †\n Yes †\n \n \n Container sandbox\n Yes (guarded)\n Yes\n Yes (guarded, scratch)\n Yes (in container)\n Yes †\n Yes †\n \n \n\n\n† :fetch / :search run in the host process — no sandbox constrains them.\n\nA :virtual agent never advertises a Shell tool it can never run — Agent#chat attaches Shell only when @sandbox.supports?(:shell). ReadFile/WriteFile/Glob are always attached. See Tools.\n\n\n\nSafety refinements — safer, more legible real-FS sandboxes\n\nFive small refinements tighten the real-filesystem sandboxes (Local, Container) and make the execution environment more legible to the model. Each wires into an existing seam — no new sandbox tier, no new capability, no new dependency. Every one tightens a default or narrows scope; none widens authority silently.\n\n\n Self-describing sandbox (Sandbox#instructions). A real-FS sandbox appends one plain-text system message describing where the agent runs, so a weak local tool-caller knows its environment. Local → “You run on the host machine, cwd /path/to/repo. The real host filesystem and shell are reachable; file access is guarded to /path/to/repo.”; Container → “You run inside a docker container (image node:22-slim), cwd /workspace, network none…”. Virtual says nothing. Ordering: agent instructions → sandbox instructions → skill instructions.\n Capability-gated tool attach (Sandbox#supports?). A :virtual agent no longer advertises a Shell tool it can never run — Agent#chat attaches Shell only when @sandbox.supports?(:shell).\n Shell output truncation (Nexo::OutputTruncator). Unbounded command output (npm install, git log) is truncated before it reaches the model — strips ANSI escapes, keeps the last max_lines lines, appends a …[truncated N lines] marker, then caps at max_chars. The integer status passes through untouched. Pure line/char truncation — no tokenizer; configurable via the kwargs only.\n Read-before-write + stale guard (real-FS only). Within a session, the agent is blocked from overwriting a file it never read, or one that changed underneath it. Overwriting an existing, un-read file returns {error: \"read <path> before overwriting it\"}; a file whose mtime changed since the read returns {error: \"stale: <path> changed since you read it\"}; a new file writes freely. Real-FS only — skipped entirely on Virtual. Best-effort: mtime-based. Clobber-safety within a session only — no versioning, locking, or VCS semantics.\n \n Scoped :ask predicate (ask_when). Under :ask, Permissions.new(ask_when: ->(cap, detail) { … }) scopes which actions actually prompt a human. It only ever narrows what is auto-allowed from the “ask for everything” baseline — it never widens authority.\n\n # Only prompt for writes under /protected; auto-allow everything else.\nperms = Nexo::Permissions.new(\n mode: :ask,\n on_ask: ->(cap, detail) { ask_the_human(cap, detail) },\n ask_when: ->(cap, detail) { cap == :write && detail.to_s.start_with?(\"/protected\") }\n)\n \n \n\n\n\n\nRemote sandbox — bring your own container\n\nSandboxes::Remote contains zero vendor code. It wraps any object that satisfies a four-method contract — read, write, exec, close — and delegates the Sandbox interface to it. Switching providers is swapping the injected object, nothing else:\n\nsandbox = Nexo::Sandboxes::Remote.new(client: my_container_client)\n# read(path) -> client.read(path)\n# write(path, content) -> client.write(path, content)\n# shell(cmd, timeout:) -> client.exec(cmd, timeout:)\n# glob(pattern) -> client.exec(<pattern as a positional $1, never interpolated>)\n# close -> client.close\n\n\nVendor SDKs rarely expose exactly read/write/exec/close, so adapt them with a tiny shim object. Keep the vendor gem a soft dependency behind a lazy require that raises Nexo::MissingDependencyError when it’s absent:\n\n# A ~10-line adapter wrapping a hypothetical vendor client to the four-method contract.\nclass E2BAdapter\n def initialize(api_key:)\n require \"e2b\" # soft dep — lazy, only when you actually use it\n @sbx = E2B::Sandbox.create(api_key: api_key)\n rescue LoadError\n raise Nexo::MissingDependencyError, \"E2BAdapter needs `gem \\\"e2b\\\"` in your Gemfile.\"\n end\n\n def read(path) = @sbx.files.read(path)\n def write(path, content) = @sbx.files.write(path, content)\n def exec(cmd, timeout: 30) = (r = @sbx.commands.run(cmd, timeout: timeout)\n {stdout: r.stdout, stderr: r.stderr, status: r.exit_code})\n def close = @sbx.kill\nend\n\nagent = Nexo::Agent.new(model: ENV.fetch(\"NEXO_MODEL\"),\n sandbox: Nexo::Sandboxes::Remote.new(client: E2BAdapter.new(api_key: ENV[\"E2B_API_KEY\"])))\n\n\nNexo ships only Remote plus this documented pattern — purpose-built Sandboxes::E2B / Sandboxes::Daytona classes are a possible future addition, deliberately left out of v1 because their vendor client APIs aren’t pinned yet.\n\n\n\nContainer sandbox — Docker / Apple Container\n\nSandboxes::Container runs an agent’s tools inside a throwaway OCI container via the docker (default) or Apple container CLI — shell-out only through Open3, no client gem, no Compose, no image builder. A model-driven agent never touches your host filesystem or shell directly. Declare it with the sandbox macro (image: is required — there is no default image):\n\nclass ContainerReviewer < Nexo::Agent\n model ENV.fetch(\"NEXO_MODEL\")\n sandbox :docker, image: \"node:22-slim\",\n binds: { Dir.pwd => { to: \"/workspace/repo\", mode: :ro } }\nend\n\n\nThe container cwd defaults to /workspace (a container path, not your host directory); the host dir enters only through a binds: entry.\n\nruntime: — one class, two CLIs\n\nsandbox :docker (or runtime: :docker) shells out to docker; sandbox :apple (runtime: :apple) shells out to Apple’s container binary. The run/exec surface is largely shared; where the CLIs diverge (networking especially) the class branches on the runtime. Apple container parity is NOT yet verified — the flags are encoded from the reference mapping, not confirmed against a live daemon, so every Apple flag must be verified before trust. An unknown runtime raises Nexo::ConfigurationError.\n\nHardened by default — every knob an explicit opt-out\n\nAll of the following are applied to the run argv by default and individually invertible:\n\n\n \n \n Concern\n Default\n Loosen with\n \n \n \n \n Network\n --network none (no egress)\n network: :bridge / :host / a network name\n \n \n Capabilities\n --cap-drop ALL\n cap_add: %w[NET_BIND_SERVICE ...]\n \n \n Rootfs\n --read-only\n readonly_rootfs: false\n \n \n Writable scratch\n --tmpfs <cwd>:rw (ephemeral), only when readonly_rootfs\n a :rw host bind for persistence\n \n \n Privilege escalation\n --security-opt no-new-privileges\n (not exposed)\n \n \n PIDs\n --pids-limit 512 (fork-bomb guard)\n pids_limit: (nil omits the flag)\n \n \n Memory / CPU\n unset (host decides)\n memory: / cpus:\n \n \n User / uid\n left to the image\n user: (opt-in defense-in-depth)\n \n \n Host binds\n read-only (:ro)\n per-bind { to:, mode: :rw }\n \n \n Env vars\n none\n env: { \"KEY\" => \"val\" } → one -e KEY=val per entry\n \n \n\n\nBind spec forms:\n\nbinds: { \"/host/proj\" => \"/workspace/proj\" } # -> :ro\nbinds: { \"/host/proj\" => { to: \"/workspace/proj\", mode: :rw } } # -> :rw\n\n\nNon-root is not forced. The image’s own uid is respected; user: is an opt-in. The other hardening applies regardless of uid.\n\nEvery argument is passed to Open3 as an array, never string-interpolated, so file contents and commands can’t break out of the argv. Paths are expanded against the container cwd; a path that escapes raises SecurityError. A denied/failed tool op surfaces as { error: ... } through the gated tool layer; the sandbox itself raises only on misuse — a missing binary (Nexo::ConfigurationError naming the binary), a path escape (SecurityError), or a container start failure (Nexo::Error).\n\nLifecycle — ephemeral by default, opt-in reconnect\n\nThe container starts lazily on first tool use and its id is memoized.\n\n\n Ephemeral (default, reconnect: false): close force-removes the container and clears the memo. Idempotent. A standalone container-backed agent tears its container down on Agent#close; a workflow driving one through run_agent shares the run’s sandbox, so teardown happens once at the end of the run in Workflow.execute’s ensure.\n Reconnect (name: + reconnect: true): every container is tagged at run with an exact identity label — --label nexo.sandbox.id=<name>. On start the sandbox looks up that container by the exact label filter, not a name substring, and reuses/restarts it instead of creating a new one; close leaves it running/stopped so a later sandbox with the same name reattaches. The default container name is nexo-<run-id>.\n \n Exact match, never a substring. A container merely named <name>x is never reattached — the label filter is exact.\n Ambiguity raises, never guesses. If more than one container carries the same identity label, reconnect raises Nexo::Error rather than pick one.\n Reconnect never crosses runtimes. A :docker container is never reattached by an :apple sandbox or vice versa.\n \n \n\n\nHonest caveats\n\n\n Network-none breaks installs. npm install / bundle install need egress; with the default network: :none they fail. Pass network: :bridge or bake dependencies into the image.\n Read-only rootfs needs the scratch. With --read-only, only the tmpfs at cwd (and any :rw bind) is writable, and the tmpfs is ephemeral — lost on close. Persist via a :rw bind.\n Non-root is recommended, not forced. The default hardening holds regardless of uid; set user: for defense-in-depth.\n Apple container parity is NOT yet verified — especially networking. Every Apple flag in the parity table is UNVERIFIED; confirm against Apple’s CLI before trusting the :apple runtime in production.\n Reconnect is Docker-only today. reconnect: true combined with runtime: :apple raises Nexo::ConfigurationError at the point reconnect would run. Use runtime: :docker for reconnect, or run an ephemeral :apple sandbox.\n\n\nLive example\n\nA runnable end-to-end container example is in the repo — the agent reads the mounted repo but never touches your host directly, with no network, dropped capabilities, a read-only rootfs, and the host repo bind-mounted read-only. When the run ends, agent.close tears the container down.\n\nNEXO_LIVE=1 NEXO_MODEL=gemma3:12b ruby -Ilib examples/container_review.rb /path/to/repo\n\n\n\n View examples/container_review.rb on GitHub →\n\n\n\n\nNext steps\n\n\n \n \n Permissions\n \n \n The second safety axis — what tools may do, on top of where they run.\n \n \n\n \n \n Tools\n \n \n The four sandbox-backed tools gated by these seams." }, { "id": "documentation-nexo-sessions", "title": "Sessions", "collection": { "label": "pages", "name": "Posts" }, "categories": "", "tags": "", "url": "/documentation/nexo/sessions/", "content": "A Workflow is fire-and-finish. A Nexo::Session is the other half: a remembering instance of an agent, addressed by (agent_name, instance_id), that accumulates context across separate invocations.\n\nNexo::Session.resume(Assistant, \"user-42\").prompt(\"My name is Mac.\")\n# ... a later request, job, or process ...\nNexo::Session.resume(Assistant, \"user-42\").prompt(\"What is my name?\")\n# => \"...Mac...\" — the persisted thread carried the earlier turn\n\n\nresume finds-or-creates the one thread for that pair (the pair is unique — one thread per pair) and returns a session whose #prompt appends to it. #prompt takes the same max_turns: and &on_event block as Agent#prompt, yielding the same (:tool_call | :tool_result | :done, payload) events. Extra keywords are forwarded to the agent constructor (e.g. Nexo::Session.resume(Assistant, \"u1\", cwd: repo)).\n\n\n A session adds only memory + addressability — never authority. Its sandbox, permissions (default :read_only), skills, MCP servers, and fetch_allow are exactly the agent’s; opening or resuming a session never widens what the agent can do. The persisted record supplies the thread; the agent supplies the tools/skills/instructions onto it.\n\n\n\n\nComposition — acts_as_chat, owned by the host\n\nMessage persistence is RubyLLM’s acts_as_chat — Nexo defines no message table and serializes nothing. The host Rails app owns all four persistence models (Chat, Message, ToolCall, Model), generated by ruby_llm’s own installer:\n\nrails g ruby_llm:install # generates the Chat/Message/ToolCall/Model models + migrations\n\n\nOne setup step beyond the installer: the session chat model must be addressable, so add two columns and a unique composite index to the generated chats table:\n\nclass AddNexoAddressingToChats < ActiveRecord::Migration[8.0]\n def change\n add_column :chats, :agent_name, :string\n add_column :chats, :instance_id, :string\n add_index :chats, [:agent_name, :instance_id], unique: true\n end\nend\n\n\nTell Nexo which model hosts sessions (only if it isn’t ruby_llm’s default Chat):\n\nNexo.configure { |c| c.session_chat_model = \"Chat\" } # default; a String class name,\n # constantized lazily at resume time\n\n\n\n\nRails-only durability — plain Ruby is in-memory\n\nDurable sessions require ActiveRecord. Backend selection guards on defined?(::ActiveRecord::Base) and the host chat model being defined (mirroring how RunStore only uses the AR store when Nexo::WorkflowRun is present):\n\n\n Rails (durable): the thread is a chats row; acts_as_chat’s callbacks persist every message. It survives across requests, jobs, and process restarts.\n Plain Ruby (in-memory): a process-wide store holds a live RubyLLM::Chat per pair. The thread lives only for the process — a fresh process starts empty. This is documented, non-durable behavior, not a bug.\n\n\nRe-applying the agent’s instructions on every resume is idempotent: acts_as_chat stores instructions as role: :system messages, and Nexo re-applies them with with_instructions (replace semantics) so the stored thread keeps exactly one copy across resumes rather than accumulating duplicate system messages. The runtime tools (the four sandbox tools + MCP + fetch) are re-attached each resume — they are not persisted, and that is correct.\n\n\n\nRetention, PII, and #close — the honest trade-off\n\nA continuing session is a persistence surface, and that has real costs:\n\n\n Stored messages persist until you delete them, and may contain sensitive data. A long-lived thread accumulates whatever the user and tools put into it. Nexo does not redact, expire, or GC anything — retention is your responsibility. Treat the chats/messages tables as PII stores and apply your own retention policy.\n \n Close sessions that hold resources. If the agent declares MCP servers (stdio/SSE) or fetch, a session holds live subprocesses/sockets. Call #close when done — it delegates to Agent#close, tearing those down (idempotent, safe with nothing held):\n\n session = Nexo::Session.resume(InboxAssistant, \"user-42\")\nbegin\n session.prompt(\"Summarize my unread threads.\")\nensure\n session.close # releases the agent's MCP/stdio/SSE connections\nend\n \n \n\n\n\n\nLive example\n\nA runnable, env-gated two-prompt resume is in the repo:\n\nNEXO_LIVE=1 NEXO_MODEL=… ruby -Ilib examples/chat_session.rb\n\n\n\n View examples/chat_session.rb on GitHub →\n\n\n\n\nNext steps\n\n\n \n \n Loops\n \n \n The engine that drives an agent's tool-calling turns.\n \n \n\n \n \n Concurrency\n \n \n Fan out sessions and agents without tripping rate limits." }, { "id": "documentation-nexo-skills", "title": "Skills", "collection": { "label": "pages", "name": "Posts" }, "categories": "", "tags": "", "url": "/documentation/nexo/skills/", "content": "A skill is a SKILL.md package — frontmatter plus instructions — that teaches the model how you want a task done. Skills guide reasoning; the sandbox-backed tools perform execution. Nexo does not implement skill loading; it composes the ruby_llm-skills gem so you attach a skill with one macro and no loader setup.\n\n\n\nA skill package\n\nDrop a package under app/skills/ (or scaffold one — see below):\n\napp/skills/\n└── triage/\n ├── SKILL.md # frontmatter (name, description) + process steps\n └── references/ # supporting docs the skill can cite\n\n\n---\nname: triage\ndescription: Triage incoming issues by severity and route them to the right owner.\n---\n\n# Triage\n\n## Process\n1. Classify the issue severity.\n2. Route to the right owner.\n\n\nReference it with the skills macro — its instructions are layered on top of the agent’s own, in declaration order:\n\nrequire \"nexo\"\n\nclass TriageAgent < Nexo::Agent\n model ENV.fetch(\"NEXO_MODEL\") # any ruby_llm model — never a hardcoded vendor default\n skills :triage # one macro, no loader wiring\nend\n\nTriageAgent.new.chat # chat built with the base sandbox tools + the skill's instructions\n\n\n\n\nScaffold a skill\n\nScaffold a new skill package with the generator (creates a valid SKILL.md plus a references/ directory):\n\nrails g nexo:skill triage\n# create app/skills/triage/references/.keep\n# create app/skills/triage/SKILL.md\n\n\n\n\nAn optional dependency\n\nruby_llm-skills is an optional dependency — required lazily only when you use a skill. Without it installed, require \"nexo\" still loads; touching a skill raises a clear Nexo::MissingDependencyError telling you to add gem \"ruby_llm-skills\". Referencing a skill that does not exist raises Nexo::Error naming the missing SKILL.md path.\n\n\n\nSkill tools stay gated\n\nA skill contributes instructions only. A loaded skill ships no independent tools, and Nexo deliberately does not attach ruby_llm-skills’ progressive-disclosure tool (which reads files outside the sandbox). The model reaches a skill’s references//scripts/ files through Nexo’s own permission-gated, sandbox-backed tools — so attaching a skill never widens what an agent can do beyond its configured sandbox/permission mode.\n\n\n Safe by default: skills add reasoning, never authority. A :read_only agent with a skill is still :read_only.\n\n\n\n\nLive example\n\nA runnable example points a code-reviewer agent at a local Ollama model, attaches a ruby-code-review skill, and accounts tokens per prompt:\n\nNEXO_MODEL=gemma3:12b ruby -Ilib examples/code_reviewer.rb\n\n\nThe skill package it uses lives at examples/skills/ruby-code-review/SKILL.md in the repo.\n\n\n View examples/code_reviewer.rb on GitHub →\n\n\n\n\nNext steps\n\n\n \n \n Loops\n \n \n The engine that runs the skilled agent's turns.\n \n \n\n \n \n Tools\n \n \n The sandbox-backed tools a skill teaches the model to use." }, { "id": "documentation-nexo-tools", "title": "Tools", "collection": { "label": "pages", "name": "Posts" }, "categories": "", "tags": "", "url": "/documentation/nexo/tools/", "content": "Nexo attaches four sandbox-backed tools — ReadFile, WriteFile, Shell, and Glob — each gated by the sandbox and permission seams. Which tools attach depends on what the sandbox supports; what they may do depends on the permission mode.\n\n\n\nThe four sandbox tools\n\n\n \n \n Tool\n Capability\n What it does\n Attached when\n \n \n \n \n ReadFile\n :read\n Read a file from the sandbox\n Always\n \n \n WriteFile\n :write\n Write a file into the sandbox\n Always (gated by the permission mode)\n \n \n Glob\n :glob\n Match files by pattern\n Always\n \n \n Shell\n :shell\n Run a shell command in the sandbox\n Only when sandbox.supports?(:shell)\n \n \n\n\nReadFile/WriteFile/Glob are always attached. Shell attaches only when the sandbox supports it — so a :virtual agent never advertises a Shell tool it can never run. Local/Container support all four capabilities; Virtual supports everything but :shell (it raises NotImplementedError on purpose — in-memory means no command execution).\n\n\n Safe by default: under :read_only, :read/:glob are auto-allowed and :write/:shell are denied — the agent can look but not touch. Grant individual capabilities with Permissions.new(mode: :read_only, allow: %i[read glob fetch]) without changing the mode.\n\n\n\n\nShell — output truncation\n\nUnbounded command output (npm install, git log) is truncated before it reaches the model, so a single command can’t blow a small context window. Tools::Shell wraps stdout/stderr through Nexo::OutputTruncator.call(text, max_lines: 200, max_chars: 16_000) — strips ANSI escapes, keeps the last max_lines lines, appends a …[truncated N lines] marker, then caps at max_chars. The integer status passes through untouched.\n\nPure line/char truncation — no tokenizer; configurable via the kwargs only (no global config, no per-agent macro).\n\n\n\nWriteFile — read-before-write + stale guard\n\nWithin a session, the agent is blocked from overwriting a file it never read, or one that changed underneath it. Agent#chat builds one Nexo::ReadTracker per chat and threads it into ReadFile (records (path, mtime) on a successful read) and WriteFile (enforces):\n\n\n Overwriting an existing, un-read file returns {error: \"read <path> before overwriting it\"}.\n A file whose mtime changed since the read returns {error: \"stale: <path> changed since you read it\"}.\n A new file writes freely.\n\n\nThe guard is real-FS only — skipped entirely on Virtual (nil mtime) and when no tracker is passed (direct tool construction). Best-effort: mtime-based, so a sub-second external edit may slip past the stale check (read-before-write is the primary guard). Clobber-safety within a session only — no versioning, locking, or VCS semantics.\n\n\n\nFailure model — errors, not exceptions\n\nA denied capability returns { error: ... } to the model (recoverable) and never raises into the loop — identical to a sandbox tool failure. A path that escapes the workspace raises SecurityError (sandbox misuse); everything else surfaces as recoverable context for the model to adapt to.\n\n\n\nWeb tools — fetch and search\n\nThe fetch tool for reading the web and the search tool for discovering URLs live in the Web guide. They are gated by their own :fetch and :search capabilities (denied under :read_only exactly like :shell) plus a host allow-list / an injected backend, and they run in the host process — no sandbox constrains them, not even a --network none container.\n\nThese are sandbox refinements as much as tool behavior — see Sandboxes for the guard details behind each capability, and MCP for attaching external tool servers through the protocol.\n\n\n\nNext steps\n\n\n \n \n MCP\n \n \n Attach external MCP servers behind a fail-closed allow-list.\n \n \n\n \n \n Web\n \n \n The fetch and search tools for reading and discovering the web." }, { "id": "documentation-nexo-web", "title": "Web", "collection": { "label": "pages", "name": "Posts" }, "categories": "", "tags": "", "url": "/documentation/nexo/web/", "content": "Two tools give an agent safe, default-denied web access. Nexo::Tools::Fetch reads a URL with a stdlib HTTP(S) GET, gated by a :fetch capability and a host allow-list. Nexo::Tools::WebSearch discovers URLs, gated by a :search capability and a host-injected backend. They pair: search finds URLs, fetch reads one.\n\n\n Safe by default: :fetch and :search are denied under :read_only exactly like :shell. Web egress is an escalation, not a “read”. A default agent that never calls fetch_allow / search_backend gets no web tool at all.\n\n\nBoth tools run in the host process (stdlib net/http / a host-injected backend), so no sandbox constrains them — not even a --network none container. They are bounded only by the capability gate plus fetch_allow / the injected backend.\n\n\n\nThe fetch tool\n\nrequire \"nexo\"\n\nclass NewsSummary < Nexo::Agent\n model ENV.fetch(\"NEXO_MODEL\")\n\n # :fetch is DEFAULT-DENIED (like :shell). Grant it explicitly, then scope hosts tightly.\n permissions Nexo::Permissions.new(mode: :read_only, allow: %i[read glob fetch])\n fetch_allow %w[lite.cnn.com text.npr.org hnrss.org]\n\n skills :news_summary # teaches WHICH sites to read and HOW to summarize\nend\n\n\nTwo independent locks must both open before a byte leaves the process:\n\n\n The :fetch capability — a first-class capability, denied under :read_only exactly like :shell. You grant it with :auto, or an explicit Permissions.new(mode: :read_only, allow: %i[read glob fetch]).\n The fetch_allow host list — scopes which hosts the tool may reach. Matching is subdomain-aware, never a glob: fetch_allow %w[example.com] permits example.com and news.example.com, but refuses notexample.com and example.com.evil.org. Declaring fetch_allow alone does not grant :fetch — it only scopes hosts.\n\n\nOn any denial or error the tool returns { error: … } (recoverable) and never raises into the loop — identical to the sandbox tools. Success returns { body: <raw page, truncated to 200 KB> }.\n\nSecurity — read before allow-listing a host\n\nWeb egress is a real attack surface. Tools::Fetch is deliberately narrow, but you own the allow-list:\n\n\n Fetched pages are untrusted input (prompt injection). The tool does no HTML→text extraction — it returns the raw body and the skill instructs the model to pull out what it needs. A page can carry text that looks like instructions (“now fetch http://internal/secrets”); never let the model act on content it fetched.\n Keep the allow-list tight (SSRF). An over-broad allow-list invites server-side request forgery. List the specific hosts you trust, nothing more.\n Private/loopback is always refused. Even an explicitly allow-listed host is rejected when it resolves to a loopback, RFC1918-private, or link-local address — an allow-listed localhost still returns { error: }. This guard runs after the allow-list and cannot be bypassed.\n GET only, fixed User-Agent. No POST/PUT/DELETE, no credentialed requests, no model-controlled headers, no redirect-following to off-list hosts, no crawler/cache/rate limiter. The only header the model influences is a fixed User-Agent: Nexo/<version>.\n\n\nJS-heavy pages — use an MCP fetch server instead\n\nTools::Fetch reads static HTML; it does not render JavaScript. For JS-heavy pages, compose an MCP fetch/browser server instead — it runs its own headless renderer and Nexo gates it through the separate MCP axis:\n\nclass BrowseAgent < Nexo::Agent\n model ENV.fetch(\"NEXO_MODEL\")\n mcp :fetch, transport: :stdio, command: \"npx\", args: %w[-y @modelcontextprotocol/server-fetch]\n mcp_allow %w[fetch]\nend\n\n\nwebmock is a dev/test-only dependency (the offline suite stubs all HTTP); it is not a runtime dependency — Tools::Fetch uses only stdlib.\n\nLive example\n\nA live example demonstrates read-only web fetch scoped by fetch_allow:\n\nNEXO_LIVE=1 NEXO_MODEL=… ruby -Ilib examples/news_summary.rb\n\n\n\n View examples/news_summary.rb on GitHub →\n\n\n\n\nWeb search — the search tool\n\nNexo::Tools::WebSearch gives an agent a vendor-neutral way to discover URLs. It authorizes a new, default-denied :search capability, then delegates the query to a host-injected backend and returns normalized, capped results. Nexo ships no search provider — you inject the backend.\n\nrequire \"nexo\"\n\nclass ResearchAgent < Nexo::Agent\n model ENV.fetch(\"NEXO_MODEL\")\n\n # :search is DEFAULT-DENIED (like :fetch/:shell). Grant it explicitly.\n permissions Nexo::Permissions.new(mode: :read_only, allow: %i[read glob fetch search])\n fetch_allow %w[lite.cnn.com text.npr.org]\n search_backend MyBraveAdapter.new(ENV.fetch(\"BRAVE_API_KEY\")) # host-owned; Nexo ships none\nend\n\n\nTwo things must both be true before the tool runs:\n\n\n The :search capability — a first-class capability, denied under :read_only exactly like :fetch/:shell. Grant it with :auto, or an explicit Permissions.new(mode: :read_only, allow: %i[read glob search]).\n A declared search_backend — the injected provider. A default agent that never calls search_backend gets no search tool at all; existing agents are byte-for-byte unchanged.\n\n\nThe backend contract\n\nThe backend is any object responding to:\n\nsearch(query, **opts) -> Enumerable of {title:, url:, snippet:}\n\n\nRows may be Hashes or any object responding to #to_h. Nexo normalizes each row to {title:, url:, snippet:} (all stringified), truncates the snippet to 300 chars, and returns at most 8 rows:\n\n{ results: [{ title: \"…\", url: \"https://…\", snippet: \"… (≤300 chars)\" }, …] } # ≤8 rows\n\n\nOn any denial or error the tool returns { error: … } (recoverable) and never raises into the loop. The v1 tool exposes only query; result count, region, safesearch and other **opts stay a host-side backend concern and are never populated by the tool.\n\nSecurity — read before injecting a backend\n\n\n The search tool runs in the host process, not the sandbox. Like Fetch, a --network none container does not constrain it; only the :search capability and the backend’s own scope do.\n The backend is trust-bearing. Nexo hands it the raw query and returns its results to the model as untrusted input — snippets can carry prompt-injection text. Choose a reputable backend, and never let the model act on a snippet’s instructions.\n\n\nLive example\n\nA live example demonstrates a host-injected search_backend plus fetch:\n\nNEXO_LIVE=1 NEXO_MODEL=… ruby -Ilib examples/news_search.rb\n\n\n\n View examples/news_search.rb on GitHub →\n\n\n\n\nNext steps\n\n\n \n \n Tools\n \n \n The four sandbox-backed file and shell tools.\n \n \n\n \n \n MCP\n \n \n Attach external MCP servers for more capabilities." }, { "id": "documentation-nexo-workflows", "title": "Workflows", "collection": { "label": "pages", "name": "Posts" }, "categories": "", "tags": "", "url": "/documentation/nexo/workflows/", "content": "An agent accumulates context — it keeps a conversation going. A workflow fires and finishes: a finite job with a stable runId, a status, a payload, a result, and an ordered, inspectable event log. Subclass Nexo::Workflow, implement #call(payload), and run it.\n\n\n\nLifecycle\n\nrequire \"nexo\"\n\nclass SummarizeDocument < Nexo::Workflow\n def call(payload)\n emit(:started, doc_id: payload[:doc_id])\n summary = payload[:text].to_s.slice(0, 280) # pure Ruby — no Agent needed\n emit(:summarized, length: summary.length)\n { summary: summary }\n end\nend\n\nrun = SummarizeDocument.run(doc_id: 123, text: \"Long text…\")\nrun.id # => \"0191d6b2-…\" (UUID v7 string, time-ordered)\nrun.status # => \"done\"\nrun.result # => { \"summary\" => \"Long text…\" }\n\n\n#call receives a symbol-keyed payload; the stored payload and result read back string-keyed (they survive a JSON round-trip identically whether the run lives in memory or in the database).\n\nFailure model — workflows re-raise\n\nA workflow that raises is recorded as failed with the error message and the exception still propagates to your caller:\n\nrun = BoomWorkflow.run # raises — but the run is persisted as failed first\n# => RuntimeError: kaboom\n\n\nThis is deliberately the opposite of a Nexo tool failure, which returns { error: … } and never raises into the agent loop. A tool error is recoverable context for the model; a workflow failure is a job that did not complete. By default a failed run is not retried — the exception is yours to handle. Runs orphaned in \"running\" by a crashed worker are swept to \"interrupted\" by reconcile_interrupted!.\n\n\n\nThe event log — emit and nexo logs\n\nemit(:type, data) appends an ordered event (type, data, at) and persists it incrementally. Inspect a run’s log in plain Ruby:\n\nNexo::Workflow.logs(run.id) { |ev| puts \"#{ev[\"at\"]} #{ev[\"type\"]}\" }\n\n\nor, in a Rails app, from the terminal:\n\n$ bundle exec rake \"nexo:logs[0191d6b2-7c4a-7e1f-9a3b-2f5c8d1e6b00]\"\n[2026-06-29T14:02:01Z] started {\"doc_id\"=>123}\n[2026-06-29T14:02:01Z] summarized {\"length\"=>280}\n\n\n\n\nWith or without Rails\n\nWith no Rails loaded, runs record to an in-memory store — workflows run, emit, and Nexo::Workflow.logs works, all offline with no database. In a Rails app, install the migration and runs persist to a nexo_workflow_runs table:\n\nrails g nexo:workflows\nrails db:migrate\n\n\nThe same Workflow code drives either backend; Nexo::RunStore.default selects ActiveRecord when it is available and the in-memory store otherwise. The schema uses portable json columns (SQLite and PostgreSQL alike) and a UUID string primary key.\n\n\n\nInput staging and artifacts\n\nA run owns a sandbox — declared with the sandbox class macro (default :virtual; :local for the host filesystem rooted at the cwd macro, default Dir.pwd). It is resolved lazily: a data-only workflow that never touches files builds nothing. A Workflow accepts the same sandbox forms as an Agent — they share one resolver (Nexo::Sandboxes.resolve), so the two can’t drift, including a hardened container:\n\nclass BuildInContainer < Nexo::Workflow\n sandbox :docker, image: \"node:22-slim\" # or :apple, or { type: :docker, ... }\n def call(_payload) = { ok: true }\nend\n\n\nstage(files) writes provided inputs into that sandbox before your #call work begins. It takes either a { \"path\" => \"content\" } hash or an array of { path:, content: } hashes, emits a :staged event with the count, and returns the count staged.\n\nartifact(name, content:) records a named deliverable on the run — a digest, a report, an improved file, a generated script. The body is written to the sandbox at /artifacts/<name> (so later steps can read it) and recorded on the run. run.artifacts reads it back as an ordered array of string-keyed hashes ({\"name\" =>, \"content\" =>, \"at\" =>}):\n\nclass BuildDigest < Nexo::Workflow\n def call(payload)\n stage(payload[:files]) # baseline + extras into the sandbox\n artifact(\"digest.md\", content: summarize(sandbox.read(\"/workspace/baseline.md\")))\n { ok: true }\n end\nend\n\nrun = BuildDigest.run(files: [{ path: \"baseline.md\", content: \"…\" }])\nrun.artifacts.first[\"name\"] # => \"digest.md\"\nrun.artifacts.first[\"content\"] # => \"…the digest body…\"\n\n\nYou can also render an artifact from a template you control with from: — no templating engine, just stdlib ERB:\n\n# from: is a real disk file when it exists, else a staged sandbox path.\nartifact(\"digest.md\", from: \"app/templates/digest.md.erb\",\n locals: { title: \"Weekly\", baseline: sandbox.read(\"/workspace/baseline.md\") })\n\n\n\n Templates are code, not data. ERB executes arbitrary Ruby. A template passed to artifact(from:) must be a trusted, developer-authored file — never model output or user-uploaded content. Rendering a model-generated or uploaded template is remote code execution. If a body is untrusted, pass it as content: (inert data), not as a from: template.\n\n\nThe artifacts column ships with fresh installs. Apps installed before this release add it with:\n\nrails g nexo:artifacts\nrails db:migrate\n\n\nLive example\n\nThe full offline artifact-from-template flow is runnable in the repo:\n\nruby -Ilib examples/artifact_from_template.rb\n\n\n\n View examples/artifact_from_template.rb on GitHub →\n\n\n\n\nTasks & Actions — drive an agent\n\nA workflow can declare and drive an agent so the two primitives Nexo owns — a Workflow (the run lifecycle) and an Agent (the skilled, sandbox-backed model loop) — compose into one recipe: stage inputs → run the agent → capture artifacts. The agent class macro names the Agent subclass this workflow drives; run_agent(prompt, max_turns: 25) runs it bound to the run’s own sandbox, forwards every tool call/result and the final response into the run log as agent_* events, and closes the agent afterward.\n\nclass ReviewBaseline < Nexo::Workflow\n agent CodeReviewer # the Agent subclass this workflow drives\n\n def call(payload)\n stage(payload[:files]) # inputs into the run's sandbox\n resp = run_agent(\"Review the staged baseline and report OK or the issues.\")\n artifact(\"review.md\", content: resp.content) # capture the agent's output\n { content: resp.content }\n end\nend\n\n\nA driven run reads as one coherent story — Nexo::Workflow.logs(run.id) (and nexo:logs) interleaves the workflow’s own events with the agent’s:\n\n[…] staged {\"count\"=>1}\n[…] agent_tool_call {\"name\"=>\"read_file\", \"args\"=>{\"path\"=>\"/workspace/baseline.md\"}}\n[…] agent_tool_result {\"ok\"=>true, \"content\"=>\"…\"}\n[…] agent_done {\"content\"=>\"REVIEW OK\"}\n\n\nThe same workflow runs two ways with no code difference — Nexo stays schedulable, never a scheduler.\n\nAs a scheduled Task — invoke it from a background job (the scheduling itself lives in the host):\n\nclass ReviewBaselineJob < ApplicationJob\n def perform(files:)\n ReviewBaseline.run(files: files) # same run entry point\n end\nend\n\n# scheduled elsewhere in the host — Nexo does not schedule:\nReviewBaselineJob.perform_later(files: nightly_baseline)\n\n\nAs an interactive Action — invoke the same run from a controller after staging the uploaded files:\n\nclass ReviewsController < ApplicationController\n def create\n files = params[:files].map { |f| { path: f.original_filename, content: f.read } }\n run = ReviewBaseline.run(files: files) # identical call — no code difference\n redirect_to review_path(run.id)\n end\nend\n\n\n\n Shared-sandbox precedence. Under run_agent the agent uses the workflow’s sandbox; the agent’s own sandbox class macro is ignored (it only applies when the agent runs standalone). The agent keeps its own permissions, skills, mcp, and mcp_allow: the workflow provides the where (sandbox), the agent owns the what (permissions) and the how (skills/instructions). Driving an agent never widens its authority — its safe default (:read_only) is untouched.\n\n\nLive example\n\nA live example wraps an MCP-backed agent in a workflow and captures the digest as an artifact:\n\n\n View examples/inbox_digest_task.rb on GitHub →\n\n\n\n\nReconciling interrupted runs\n\nA crashed worker leaves runs stuck in \"running\". Nexo::Workflow.reconcile_interrupted! is a one-shot boot/deploy sweep that rewrites only \"running\" → \"interrupted\" (never touching \"done\" or \"failed\") and returns the count. It is never auto-invoked — call it from a boot hook or the shipped rake task:\n\nbundle exec rake nexo:reconcile\n\n\n\n This is not a liveness check. It cannot tell a genuinely-running run in another process from an orphaned one — so run it once at boot, before any worker starts new runs, not while workers are live.\n\n\n\n\nWhere to next\n\n\n Durable workflows — checkpoint / suspend! / resume for long-running and human-in-the-loop jobs.\n Rails — run_later, live progress broadcasting, and run-query helpers for a host UI.\n\n\n\n\nNext steps\n\n\n \n \n Durable workflows\n \n \n Pause a run durably and resume it later without redoing work.\n \n \n\n \n \n Rails\n \n \n Run the same workflow in the background with run_later." }, { "id": "documentation-tools-equipr", "title": "equipr: skills and MCP servers for coding agents", "collection": { "label": "pages", "name": "Posts" }, "categories": "", "tags": "", "url": "/documentation/tools/equipr/", "content": "Install skills, commands, and MCP servers from marketplaces and Agent Plugins (AP) sources into your coding agents: Claude Code, Codex CLI, OpenCode, and Pi.\n\nCurrent version: 0.4.0, which is what this page documents. equipr is pre-1.0, so the surface is still settling.\n\n\n\nWhat Is This?\n\nA single Go binary that:\n\n\n Fetches a source once, whether that is a git repository, an npm package, an archive URL, or a local path\n Resolves what is inside it: the plugins, and the skills, commands, and MCP servers each plugin holds\n Places every component where its target agent expects to find it, by copy or by symlink\n Records what it did, so update, status, doctor, and uninstall work from facts instead of guesses\n\n\nThe more interesting part is what equipr refuses to do.\n\nIt never registers itself as a native plugin in any agent. No /plugin, no global npm install, nothing that turns up in an agent’s own plugin list; it writes to personal and global config surfaces and stops there. It also never touches a config key it does not own. Every write into a shared agent config is a targeted single-key merge, which is why your hand-edits, your comments, and your key ordering all survive it.\n\nWhy It Exists\n\nSkills and MCP servers are distributed as git repositories, npm packages, and archives, but every coding agent expects them in a different place, in a different shape. Claude Code reads ~/.claude/skills/. OpenCode reads ~/.config/opencode/skills/ and uses a different MCP config schema. The manual answer is copying directories around and hand-editing shared JSON and TOML config files that also hold your credentials.\n\nDoing that by hand is fine once. It stops being fine at four agents, a dozen skills, and a config file you have already customized.\n\n\n\nQuick Start\n\n1. Install\n\nbrew install maquina-app/tap/equipr\n\n\n2. Add a source\n\n$ equipr add https://github.com/coreyhaines31/marketingskills\nFetching https://github.com/coreyhaines31/marketingskills\nAdded marketingskills (marketplace, fetched via git) with 1 plugin(s)\n - marketing-skills 2.10.0\n\n\n3. See what it holds\n\n$ equipr list\nmarketingskills marketplace\n marketing-skills 2.10.0 49 skills\n\n\n4. Install a component into your agents\n\n$ equipr install marketingskills/marketing-skills:seo-audit --yes\nInstalled 1 component(s) into 3 agent(s):\n [claude-code] seo-audit (copy -> ~/.claude/skills/seo-audit)\n [opencode] seo-audit (symlink -> ~/.config/opencode/skills/seo-audit)\n [pi] seo-audit (symlink -> ~/.pi/agent/skills/seo-audit)\n\n\nThree agents rather than four, because Codex was not installed on the machine this run was captured on.\n\nWithout --yes, install is interactive: pick agents, then pick components. Components all start checked, and so do the agents whose binary is on $PATH, so pressing enter through both installs everything into every agent you actually have.\n\n5. Check your installs\n\n$ equipr status\nSTATE COMPONENT SOURCE/PLUGIN AGENT MECH TARGET\nok seo-audit(s) marketingskills/marketing-skills claude-code copy ~/.claude/skills/seo-audit\nok seo-audit(s) marketingskills/marketing-skills opencode symlink ~/.config/opencode/skills/seo-audit\nok seo-audit(s) marketingskills/marketing-skills pi symlink ~/.pi/agent/skills/seo-audit\n\n$ equipr doctor\nNo issues found.\n\n\n\n\nInstallation\n\nEvery release publishes binaries for darwin, linux, and windows on amd64 and arm64. macOS binaries are ad-hoc codesigned, because an unsigned Mach-O binary is killed on Apple Silicon.\n\n# Homebrew (macOS and Linux)\nbrew install maquina-app/tap/equipr\n\n# Install script: /usr/local/bin when that is writable, otherwise ~/.local/bin\ncurl -fsSL https://github.com/maquina-app/equipr/releases/latest/download/install.sh | sh\n\n# From source (Go 1.24+), into any directory on your PATH\ngo build -o ~/.local/bin/equipr ./cmd/equipr\n\n\ngo install ./cmd/equipr works too, but it puts the binary in $(go env GOPATH)/bin, which is not on everyone’s PATH.\n\nDebian and RPM packages and raw archives are on the releases page.\n\nVerify the install:\n\n$ equipr --version\nequipr version 0.3.3\n\n\nequipr doctor reports on the health of your installs. It says nothing about the binary itself, and with nothing installed yet it prints No issues found. and exits 0.\n\n\n\nConcepts\n\n\n \n \n Term\n What it means\n \n \n \n \n origin\n Where content comes from, as you type it into add. Auto-detected as git, npm, archive, or local path.\n \n \n source\n One added origin, fetched into the cache and recorded in the registry. Either a marketplace or a single AP package.\n \n \n source-id\n The short handle a source is addressed by, derived from the origin, usually the repository or directory name.\n \n \n plugin\n One installable unit inside a source. An AP source has exactly one; a marketplace can have many.\n \n \n component\n One installable thing inside a plugin: a skill, a command, or an MCP server.\n \n \n agent\n An install target: claude-code, codex, opencode, or pi.\n \n \n mechanism\n How a component is placed: copy or symlink.\n \n \n\n\nAddressing Grammar\n\nEverything nests, and every command addresses one of these three levels:\n\n<source-id> marketingskills\n<source-id>/<plugin> marketingskills/marketing-skills\n<source-id>/<plugin>:<component> marketingskills/marketing-skills:seo-audit\n\n\nSource Types\n\n\n \n \n Type\n Detected by\n Contains\n \n \n \n \n Marketplace\n .claude-plugin/marketplace.json at the root\n Many plugins, in subdirectories\n \n \n Agent Plugins package\n plugin.json at the root\n Exactly one plugin\n \n \n\n\nOrigin Kinds\n\nThere are three kinds: git, npm, and archive. They are resolved in a fixed order, and the order does more work than the individual rules:\n\n\n An explicit npm: prefix wins immediately: npm\n Anything ending in .zip, .tar.gz, or .tgz: archive. The test is on the string suffix alone, so a local ./pkg.zip classifies here too. It is checked before git, which is why https://host/pkg.zip resolves as an archive\n Contains ://, starts with git@, or ends in .git: git\n Exists on disk: npm if it is a directory holding package.json and no .git, otherwise git. Any existing path qualifies, including a plain file\n Nothing above matched and nothing exists at that path: npm. This is the fallback, and it is how a bare package name like express resolves\n\n\nA local path is not a fourth kind. Steps 2 and 4 sort it into one of the three, and step 4 holds a trap: if a directory named express happens to exist in your working directory, equipr add express takes it as that directory rather than the npm package. Write npm:express to force the package.\n\nAn origin can also name a subdirectory, either as a fragment or as a forge tree URL:\n\nequipr add https://github.com/owner/repo#plugins/foo\nequipr add https://github.com/owner/repo/tree/main/plugins/foo\n\n\nThe /tree/<ref>/<path> form is http(s) only, and equipr clones the default branch shallowly. A tree URL naming some other branch is reported back to you with the # form as the way forward, rather than being quietly resolved against the default branch.\n\nComponent Types\n\n\n \n \n Type\n Where it lives in a plugin\n \n \n \n \n skill\n A directory under skills/ holding a SKILL.md\n \n \n command\n A .md file under commands/\n \n \n MCP server\n An entry in the plugin’s mcp.json\n \n \n\n\nCommands are normalized to <name>/SKILL.md for every agent, so a plugin’s commands and skills land in the same place and are discovered the same way.\n\n\n\nCommands\n\n\n \n \n Command\n Purpose\n \n \n \n \n equipr add <origin>\n Fetch an origin, resolve its contents, register it\n \n \n equipr list [source-id \\| source-id/plugin]\n List the registry: sources, their plugins, and what each holds\n \n \n equipr show <source-id>[/plugin]\n Details for a source or one plugin\n \n \n equipr install <source>/<plugin>[:<component>]\n Place a plugin’s components into agents\n \n \n equipr update\n Re-fetch every source that has installs and re-apply each recorded component\n \n \n equipr remove <source-id>\n Drop a source from the registry\n \n \n equipr uninstall <source>/<plugin>[:<component>]\n Remove installed files and records\n \n \n equipr doctor\n Read-only health check\n \n \n equipr status\n Read-only report of every recorded install\n \n \n\n\nadd\n\nRe-running add on a source that already exists refreshes it. Git sources pull; npm and archive sources are re-fetched.\n\nequipr add https://github.com/coreyhaines31/marketingskills\n\n\nlist\n\n-c / --components expands each plugin to its component names. Passing <source>/<plugin> prints what show prints.\n\nequipr list marketingskills --components\n\n\ninstall\n\nInteractive by default: pick agents, then pick components. Components start checked; agents start checked when their binary is on $PATH (see Detection). Use -a to pin targets and --yes to skip the prompts.\n\nequipr install marketingskills/marketing-skills:seo-audit --yes -a claude-code\n\n\nupdate\n\nTakes no arguments. It operates on every source that has installs.\n\nequipr update --dry-run\n\n\n--dry-run still re-fetches, so it can report “would update v1 to v2”, but it writes nothing and never prompts.\n\nremove and uninstall\n\nThe two are deliberately separate:\n\n\n remove deregisters the source but leaves the installed files and their records in place, so uninstall can still clean them up. doctor reports those records as orphaned-record.\n remove --purge does it all at once: drops the records, deletes the installed files, and removes the cache clone.\n uninstall removes files and records for a plugin or a single component, and works on orphaned records after a remove.\n\n\nequipr uninstall marketingskills/marketing-skills:seo-audit\nequipr remove marketingskills --purge\n\n\ndoctor\n\nRead-only. Reports four conditions: orphaned-record, vanished-config-dir, broken-symlink, and modified-cache. It exits 5 when it finds issues and 0 when clean, so scripts checking $? should expect that.\n\nstatus\n\nA report of every recorded install: state (ok, modified, or missing), component, source and plugin, agent, mechanism, and target path. Writes nothing.\n\n\n\nFlags\n\nPersistent\n\nOne flag is registered on the root command and applies everywhere.\n\n\n \n \n Flag\n Effect\n \n \n \n \n -y, --yes\n Assume yes to all prompts: install into every present agent, install all components, resolve conflicts as overwrite\n \n \n\n\n--json is not persistent, despite looking like it should be. It is registered on each of the nine commands that produce results, and completion and help never register it. So equipr --json on its own is an error, and so is equipr completion zsh --json.\n\nPer Command\n\n\n \n \n Command\n Flag\n Values\n Default\n \n \n \n \n install\n -a, --agent (repeatable)\n claude-code, codex, opencode, pi\n Prompt\n \n \n install\n -c, --component\n A component name, the same thing as the :name suffix\n Prompt (all with --yes)\n \n \n install\n --mechanism\n copy, symlink\n The per-agent default\n \n \n install\n --on-conflict\n prompt, overwrite, skip, fail\n prompt\n \n \n update\n --on-conflict\n prompt, overwrite, skip, fail\n prompt\n \n \n update\n --dry-run\n Report only, write nothing\n Off\n \n \n remove\n --purge\n Also delete records, files, and cache\n Off\n \n \n list\n -c, --components\n Expand plugins to component names\n Off\n \n \n\n\nChoosing a Mechanism\n\n\n \n \n Mechanism\n Pick it when\n \n \n \n \n symlink\n You want the agent to track the cache, so update is instant and the source stays the single copy on disk\n \n \n copy\n You want the installed version pinned and independent of the cache, or the agent does not reliably follow links\n \n \n\n\nThe per-agent defaults exist for a reason: whole-directory symlinks proved unreliable for Claude Code and Codex, while OpenCode and Pi dereference links at any depth. Symlinks are always created per component, never for a whole directory.\n\nChoosing a Conflict Policy\n\n--on-conflict applies when a target has been locally modified since it was installed.\n\n\n \n \n Value\n Behavior\n Use it for\n \n \n \n \n prompt\n Ask per file\n Interactive use; the default\n \n \n overwrite\n Discard local edits\n When the source is the truth\n \n \n skip\n Keep local edits and move on\n When you have deliberately customized a skill\n \n \n fail\n Abort the whole run on the first modification\n Scripts and CI, where a surprise should stop the pipeline\n \n \n\n\nExit Codes\n\nStable, and safe to script against.\n\n\n \n \n Code\n Meaning\n \n \n \n \n 0\n Success\n \n \n 1\n Generic or usage error\n \n \n 2\n Not found\n \n \n 3\n Conflict\n \n \n 4\n Environment error\n \n \n 5\n doctor found issues\n \n \n\n\n\n\nAgents\n\n\n \n \n Agent\n Detected by\n Components land in\n MCP config\n \n \n \n \n claude-code\n ~/.claude/ or claude on $PATH\n ~/.claude/skills/<name>/\n ~/.claude.json\n \n \n codex\n ~/.codex/ or codex on $PATH\n ~/.codex/skills/<name>/\n ~/.codex/config.toml\n \n \n opencode\n ~/.config/opencode/ or opencode on $PATH\n ~/.config/opencode/skills/<name>/\n ~/.config/opencode/opencode.json\n \n \n pi\n ~/.pi/agent/ or pi on $PATH\n ~/.pi/agent/skills/<name>/\n ~/.config/mcp/mcp.json\n \n \n\n\nDefault mechanism: copy for claude-code and codex, symlink for opencode and pi.\n\nDetection\n\nAn agent is offered when either signal holds: a config directory, or a binary on $PATH. Both are needed, because a freshly installed agent has no config directory until its first run, and a config directory outlives an uninstalled agent. The picker labels each agent with the evidence it found (configured, on PATH, or both), so a surprising entry explains itself.\n\nBeing offered and being selected are different things. Since 0.4.0 the picker pre-checks only the agents whose binary is on $PATH. A config-only agent is listed but starts unchecked, because a leftover config directory outlives an uninstalled agent and enter should not push skills into a directory nothing reads. The exception: when nothing at all is on $PATH, every row starts checked, so that enter is never a silent no-op. --yes is unaffected and still installs into every agent present, config-only ones included.\n\nMCP Server Writes\n\nMCP servers are never written as a whole file. equipr merges a single key into the agent’s existing config, atomically. It writes to a temporary file, then renames it, preserving the file mode. JSON goes through sjson and gjson, TOML through a dedicated writer, specifically so key order, formatting, and comments in a file you hand-edited survive the write.\n\nAgent schemas differ, and equipr writes each in its native shape. OpenCode uses an mcp key with an explicit type and command as an array, where Claude Code uses mcpServers with string inference.\n\n\n\nFiles and State\n\n\n \n \n Path\n Holds\n \n \n \n \n $XDG_CONFIG_HOME/equipr/config.toml\n Reserved; nothing reads it yet\n \n \n $XDG_STATE_HOME/equipr/sources.json\n The registry: every added source and what is in it\n \n \n $XDG_STATE_HOME/equipr/installs.json\n Install records: component, agent, target path, content hash\n \n \n $XDG_STATE_HOME/equipr/equipr.lock\n Lock file, held across mutating operations\n \n \n $XDG_CACHE_HOME/equipr/sources/<id>/\n The fetched tree, per source\n \n \n\n\nOn macOS those XDG defaults resolve under ~/Library/Application Support/equipr and ~/Library/Caches/equipr. On Linux, ~/.local/state/equipr and ~/.cache/equipr.\n\nHand-Editing\n\nUse the commands, not an editor. sources.json and installs.json are rewritten wholesale under the lock, and installs.json records a content hash per target that the conflict check compares against, so editing it by hand makes status and doctor report the wrong thing.\n\nDeleting the cache directory is the one safe destructive act: add or update re-fetches it.\n\n\n\nJSON Output\n\nNine commands accept --json: add, list, show, install, update, remove, uninstall, doctor, and status. Results go to stdout and progress to stderr, so stdout stays parseable.\n\nScript against --json, not the plain-text output. The text layout is still moving: list was restructured in 0.3.2 and status in 0.3.3, both on the same day.\n\nequipr list --json, trimmed. The real skills array holds 49 entries:\n\n{\n \"sources\": [\n {\n \"id\": \"marketingskills\",\n \"type\": \"marketplace\",\n \"kind\": \"git\",\n \"origin\": \"https://github.com/coreyhaines31/marketingskills\",\n \"plugin_count\": 1,\n \"plugins\": [\n {\n \"name\": \"marketing-skills\",\n \"version\": \"2.10.0\",\n \"description\": \"Marketing skills for AI agents — conversion optimization, copywriting, SEO, paid ads, ad creative, and growth\",\n \"author\": \"Corey Haines\",\n \"skills\": [\"ab-testing\", \"ad-creative\", \"ads\", \"ai-seo\", \"...\"]\n }\n ]\n }\n ]\n}\n\n\nequipr status --json, trimmed to one of three entries:\n\n{\n \"entries\": [\n {\n \"source_id\": \"marketingskills\",\n \"plugin\": \"marketing-skills\",\n \"component\": \"seo-audit\",\n \"component_type\": \"skill\",\n \"agent\": \"claude-code\",\n \"mechanism\": \"copy\",\n \"target\": \"~/.claude/skills/seo-audit\",\n \"version\": \"2.10.0\",\n \"state\": \"ok\"\n }\n ]\n}\n\n\n\n\nShell Completion\n\nCobra-generated completion is available for four shells: bash, zsh, fish, and powershell.\n\nequipr completion zsh > \"${fpath[1]}/_equipr\" # then restart your shell\nequipr completion bash > /etc/bash_completion.d/equipr\nequipr completion fish > ~/.config/fish/completions/equipr.fish\n\n\nequipr completion <shell> --help prints the install instructions for that shell.\n\nCompletion covers commands and flags. Source, plugin, and component names are resolved at runtime, so they are not completed.\n\n\n\nRecent Changes\n\n\n \n \n Version\n Change\n \n \n \n \n 0.4.0\n Local paths that are neither a git repository nor an npm package are rejected up front with a message naming the problem, instead of failing later as a git error. The install picker pre-checks only agents found on $PATH\n \n \n 0.3.4\n MIT license added. No change to commands, flags, output, or JSON\n \n \n 0.3.3\n status columns sized from the rows\n \n \n 0.3.2\n list nests plugins under sources with component counts, gained --components, and accepts <source>/<plugin>\n \n \n 0.3.1\n Install prompts pre-check every option; agents detected by config directory or binary on $PATH; skills/ and commands/ walked recursively; marketplace entries rooted at \"./\" resolve\n \n \n\n\nBefore 0.3.1, install prompts started with nothing selected, so pressing enter installed nothing.\n\n\n\nTroubleshooting\n\nNothing was installed\n\nThe picker toggles with space and submits with enter. Components start checked, so enter installs all of them. Deselect everything and press enter, though, and nothing installs; equipr says as much. Two other ways to end up with nothing: on 0.3.0 and earlier every row started unselected and the error wrongly claimed the plugin had no components, so upgrade or pass --yes; and since 0.4.0 a config-only agent starts unchecked, so an install can succeed while skipping an agent you expected it to reach.\n\nAn agent you do not use is offered\n\nA leftover config directory counts as configured, even with no binary installed. Check the label in the picker: configured on its own means no binary was found on $PATH, and since 0.4.0 that row starts unchecked. Delete the stale directory, or pin your targets with -a. Note that --yes ignores the distinction and installs into every agent present.\n\nA plugin shows no components\n\nMost often it ships only an agents/ directory, holding Claude Code subagents, which equipr does not model yet. equipr handles skills, commands, and MCP servers. That is a known gap in equipr; the source itself is fine.\n\nAdding a local path is rejected\n\nOrigin classification resolves any existing path it does not recognize to git, so a directory that is not a git repository, a plain file, and a local archive are each rejected before anything is fetched, with a message naming the actual problem:\n\n$ equipr add ./plaindir\nequipr: ./plaindir is neither a git repository nor an npm package directory; a local\nsource must be one or the other (run `git init` there, or point equipr at a remote origin)\n\n$ equipr add ./notes.txt\nequipr: ./notes.txt is a file, not a source; add the directory that holds plugin.json\nor .claude-plugin/marketplace.json\n\n$ equipr add ./pkg.zip\nequipr: ./pkg.zip is a local archive; equipr fetches archives over http(s) only, so\nextract it and add the directory instead\n\n\nExit code 1 in all three cases. Before 0.4.0 the first of these surfaced as a raw git clone: repository does not exist, which read like a bug in equipr and was not one.\n\nA marketplace reports fewer plugins than it has\n\nAn entry whose source is a remote reference equipr cannot resolve in-tree (a github, npm, or archive object form) is skipped and named in the output, rather than failing the whole marketplace. The fix belongs upstream in the marketplace.\n\ndoctor reports modified-cache\n\nThe cached tree no longer matches the digest recorded when it was added, which usually means something outside equipr touched the cache. Run equipr add <origin> to re-fetch and clear it.\n\ndoctor exits 5\n\nExit code 5 is documented behavior: doctor found something. Nothing crashed.\n\nRecords survive remove\n\nBy design. remove deregisters the source but leaves files and records so uninstall can still clean them, and doctor reports them as orphaned-record. Use remove --purge for the one-shot version.\n\nTwo components with the same name fail the whole source\n\nComponents are named by their own directory or file, which is not unique across a nested tree, and skills and commands share one flat install namespace. Rather than silently overwriting one with the other, add fails and names both paths. The fix belongs upstream in the source.\n\n\n\nLicense\n\nMIT. Copyright (c) 2026 Mario Alberto Chávez.\n\n\n\nNext Steps\n\n\n \n \n GitHub Repository\n \n \n Source code, releases, issues, and contribution guidelines.\n \n \n\n \n \n Announcement\n \n \n Why equipr exists, and what it refuses to do.\n \n \n\n \n \n AI Tools\n \n \n MCP servers and Claude Code plugins, the kind of thing equipr installs.\n \n \n\n \n \n Agent Plugins Specification\n \n \n The plugin format equipr resolves from a source.\n \n \n\n \n \n Agent Skills Specification\n \n \n The skill format behind every SKILL.md equipr installs." }, { "id": "documentation-tools", "title": "Tools", "collection": { "label": "pages", "name": "Posts" }, "categories": "", "tags": "", "url": "/documentation/tools/", "content": "Developer utilities for your local environment. Menu bar apps for managing databases, CLI tools for workflow automation, and helpers that make development easier.\n\n\n\nAvailable Tools\n\n\n \n \n equipr\n \n \n Install skills, commands, and MCP servers into your coding agents.\n \n \n\n \n \n Redis Menu\n \n \n macOS menu bar app for managing local Redis instances.\n \n \n\n \n \n Mongo Menu\n \n \n macOS menu bar app for managing local MongoDB instances.\n \n \n\n \n \n Git Continuity\n \n \n Transfer work-in-progress between machines without commits.\n \n \n\n\n\n\nMenu Bar Apps\n\nRedis Menu and Mongo Menu are native macOS applications that sit in your menu bar. They provide:\n\n\n One-click start/stop controls\n Visual status indicators\n Custom configuration options\n Auto-start and launch at login\n Bundled database binaries (no separate installation needed)\n\n\nRequirements\n\nBoth menu bar apps require:\n\n macOS 15.0 (Sequoia) or later\n Xcode 16.0 or later (for building from source)\n\n\n\n\nCLI Tools\n\nequipr\n\nInstall skills, commands, and MCP servers from marketplaces and Agent Plugins sources into your coding agents: Claude Code, Codex CLI, OpenCode, and Pi.\n\nequipr add https://github.com/coreyhaines31/marketingskills\nequipr install marketingskills/marketing-skills:seo-audit\n\n\nequipr places each component where each agent expects it, and merges MCP entries into your existing agent config a single key at a time, leaving your hand-edits intact.\n\nView equipr Documentation\n\nGit Continuity\n\nTransfer work-in-progress between machines without committing to git history. Perfect for moving unfinished work between office and home.\n\n# On your work machine\ngit continuity push\n\n# On your home machine\ngit continuity pull\n\n\nYour uncommitted changes, staged files, and untracked files are transferred without polluting your git history." }, { "id": "documentation-tools-mongo-menu", "title": "Mongo Menu", "collection": { "label": "pages", "name": "Posts" }, "categories": "", "tags": "", "url": "/documentation/tools/mongo-menu/", "content": "A macOS menu bar application that makes managing local MongoDB instances simple and convenient. Start, stop, and configure MongoDB with a single click.\n\n\n \n \n\n\n\n\nOverview\n\nMongo Menu sits in your macOS menu bar, providing easy access to start, stop, and configure MongoDB instances. Designed for developers who need to work with MongoDB locally and want a straightforward way to manage the database without terminal commands.\n\n\n\nFeatures\n\n\n Menu Bar Controls: Start and stop MongoDB with a single click\n Visual Status Indicator: Instantly see if MongoDB is running\n Custom Configuration: Configure data directory, log path, and port\n Auto-start Options: Start MongoDB automatically when the app launches\n Launch at Login: Start Mongo Menu when your Mac boots\n Lightweight Footprint: Minimal resource usage in the background\n\n\n\n\nRequirements\n\n\n macOS 15.0 (Sequoia) or later\n Admin privileges (for first-time setup)\n\n\n\n\nInstallation\n\nBuild from Source\n\n\n \n Clone the repository:\n\n git clone https://github.com/maquina-app/mongo-menu.git\ncd mongo-menu\n \n \n \n Run the build script:\n\n ./build.sh\n \n\n The build script will:\n\n \n Check if MongoDB binaries exist and download them if needed\n Build the application\n Place the built app in build/Release/MongoMenu.app\n \n \n \n Move the built app to your Applications folder\n \n\n\nBuild Requirements\n\n\n Xcode 16.0 or later\n Command Line Tools for Xcode\n macOS 15.0 (Sequoia) or later\n\n\n\n\nUsage\n\n\n Click the MongoDB icon in the menu bar to see status and control options\n Use “Start MongoDB” or “Stop MongoDB” to control the service\n Click “Preferences” to configure settings:\n \n Data directory location\n Log file path\n MongoDB port (default: 27017)\n Auto-start options\n Launch at login option\n \n \n\n\n\n\nConfiguration\n\nDefault Locations\n\nMongo Menu stores data in these default locations:\n\n\n \n \n Setting\n Default Path\n \n \n \n \n Data directory\n ~/.local/share/mongodb/data\n \n \n Log file\n ~/.local/state/mongodb/logs/mongodb.log\n \n \n Port\n 27017\n \n \n\n\nAll locations can be customized in the app preferences.\n\nBundled MongoDB\n\nMongo Menu bundles MongoDB binaries (version 8.0.6) specifically for Apple Silicon Macs. You don’t need to install MongoDB separately. The app handles downloading the appropriate MongoDB binaries for your Mac.\n\n\n\nTroubleshooting\n\nMongoDB Won’t Start\n\n\n Check if the port is already in use by another application\n Ensure you have write permissions to the data directory and log path\n Check the log file for specific error messages\n\n\nApp Won’t Launch\n\n\n Make sure you have macOS Sequoia (15.0) or later installed\n Try rebuilding from source using the build script\n\n\n\n\nNext Steps\n\n\n \n \n GitHub Repository\n \n \n Source code, issues, and contribution guidelines.\n \n \n\n \n \n Redis Menu\n \n \n Similar menu bar app for managing Redis instances." }, { "id": "documentation-tools-redis-menu", "title": "Redis Menu", "collection": { "label": "pages", "name": "Posts" }, "categories": "", "tags": "", "url": "/documentation/tools/redis-menu/", "content": "A macOS menu bar application that makes managing local Redis instances simple and convenient. Start, stop, and configure Redis with a single click.\n\n\n \n \n\n\n\n\nOverview\n\nRedis Menu sits in your macOS menu bar, providing easy access to start, stop, and configure Redis instances. Designed for developers who need to work with Redis locally and want a straightforward way to manage the database without terminal commands.\n\n\n\nFeatures\n\n\n Menu Bar Controls: Start and stop Redis with a single click\n Visual Status Indicator: Instantly see if Redis is running\n Custom Configuration: Configure data directory, log path, and port\n Auto-start Options: Start Redis automatically when the app launches\n Launch at Login: Start Redis Menu when your Mac boots\n Lightweight Footprint: Minimal resource usage in the background\n\n\n\n\nRequirements\n\n\n macOS 15.0 (Sequoia) or later\n Admin privileges (for first-time setup)\n\n\n\n\nInstallation\n\nBuild from Source\n\n\n \n Clone the repository:\n\n git clone https://github.com/maquina-app/redis-menu.git\ncd redis-menu\n \n \n \n Run the build script:\n\n ./build.sh\n \n\n The build script will:\n\n \n Download and compile Redis if needed\n Build the application\n Place the built app in build/Release/RedisMenu.app\n \n \n \n Move the built app to your Applications folder\n \n\n\nBuild Requirements\n\n\n Xcode 16.0 or later\n Command Line Tools for Xcode\n macOS 15.0 (Sequoia) or later\n\n\n\n\nUsage\n\n\n Click the Redis icon in the menu bar to see status and control options\n Use “Start Redis” or “Stop Redis” to control the service\n Click “Preferences” to configure settings:\n \n Data directory location\n Log file path\n Redis port (default: 6379)\n Auto-start options\n Launch at login option\n \n \n\n\n\n\nConfiguration\n\nDefault Locations\n\nRedis Menu stores data in these default locations:\n\n\n \n \n Setting\n Default Path\n \n \n \n \n Data directory\n ~/.local/share/redis/data\n \n \n Log file\n ~/.local/state/redis/logs/redis.log\n \n \n Port\n 6379\n \n \n\n\nAll locations can be customized in the app preferences.\n\nBundled Redis\n\nRedis Menu downloads, compiles, and bundles Redis binaries for your Mac. You don’t need to install Redis separately. The app handles downloading and compiling the appropriate Redis source code for your system.\n\n\n\nTroubleshooting\n\nRedis Won’t Start\n\n\n Check if the port is already in use by another application\n Ensure you have write permissions to the data directory and log path\n Check the log file for specific error messages\n\n\nApp Won’t Launch\n\n\n Make sure you have macOS Sequoia (15.0) or later installed\n Try rebuilding from source using the build script\n\n\n\n\nNext Steps\n\n\n \n \n GitHub Repository\n \n \n Source code, issues, and contribution guidelines.\n \n \n\n \n \n Mongo Menu\n \n \n Similar menu bar app for managing MongoDB instances." }, { "id": "", "title": "Open Source Tools for Rails Developers", "collection": { "label": "pages", "name": "Posts" }, "categories": "", "tags": "", "url": "/", "content": "Recuerd0\n \n \n Documentation\n \n \n Open Source\n \n \n Blog\n \n \n\n \n mobile-nav#toggle\"\n >\n \n \n \n \n \n \n \n \n \n\n\n \n \n\n \n \n \n \n \n\n \n \n \n \n Open Source Rails Tools\n \n\n \n Tools for developers\n who ship alone\n \n\n \n Generators, UI components, and AI tools extracted from production Rails\n applications. No build pipelines. No framework fatigue. Built for the\n one-person framework philosophy.\n \n\n \n \n Get Started\n \n\n \n View all projects\n \n \n \n \n \n \n\n \n \n \n Production-tested Rails tools\n \n\n \n \n \n UI Library\n Maquina Components\n \n Modern UI components for Ruby on Rails. ERB partials styled with Tailwind CSS 4.0 and Stimulus controllers. Inspired by shadcn/ui, built for the Rails way.\n \n \n \n Documentation\n \n \n \n \n \n GitHub\n \n \n \n \n \n \n AI Tools\n Rails Claude Code\n \n A marketplace of Claude Code plugins for Rails — code simplification, Rails upgrades, UI standards, MVP planning, Stimulus best practices, spec-driven development, security audits, and a Hotwire dev-server driver.\n \n \n \n Documentation\n \n \n \n \n \n GitHub\n \n \n \n \n \n \n App Scaffolding\n Maquina Generators\n \n Rails generators that produce standalone application code. Authentication, job queues, error tracking, and security — no runtime dependency. Generate once, own forever.\n \n \n \n Documentation\n \n \n \n \n \n GitHub\n \n \n \n \n \n \n Engine\n Maquina Newsletters\n \n A mountable Rails 8 engine for drafting, approving, scheduling, and batch-sending HTML newsletters. Action Text editing, an approval workflow, and background batch delivery.\n \n \n \n Documentation\n \n \n \n \n \n GitHub\n \n \n \n \n \n \n \n\n \n \n \n \n \n \n Practical tools, not perfect abstractions\n \n \n Every tool here earned its place in a shipping Rails app before it\n earned a name. No theory, no lock-in — patterns simple enough to\n own outright.\n \n \n \n\n \n \n \n Production first\n \n Every tool starts in a real application. No theoretical exercises, no “what if” features. If it's here, it's been shipped.\n \n \n \n Standard Rails\n \n ERB partials, Tailwind CSS, Stimulus only where needed. No new paradigms to learn — the Rails way, refined.\n \n \n \n One-person scale\n \n Built for developers who ship alone. Simple enough to understand, powerful enough to build real applications.\n \n \n \n \n \n \n\n\n \n \n \n \n \n \n What we're building\n \n \n \n View all posts\n \n \n \n \n \n\n \n \n \n\n \n \n August 10, 2026\n \n \n \n \n equipr: Cross-Agent Skill and MCP Server Manager\n \n \n \n equipr is out: one Go binary that installs skills, commands, and MCP servers into Claude Code, Codex, OpenCode, and Pi, with no plugin registration.\n \n \n \n Mario Alberto Chávez Cárdenas\n \n \n \n \n \n\n \n \n August 4, 2026\n \n \n \n \n Why I Removed execute_ruby from Rails MCP Server\n \n \n \n Rails MCP Server 2.0.0 removes the execute_ruby tool entirely, because the reasoning that justified it in 2025 stopped holding once agentic coding tools could run Ruby themselves.\n \n \n \n Mario Alberto Chávez Cárdenas\n \n \n \n \n\n \n View all posts\n \n \n \n \n\n\n \n \n \n \n\n \n \n \n \n\n \n Turn a feature into a shipped pull request — on a new Rails app or\n one you already have. Durable context, disciplined workflow, full\n observability from brief to branch.\n \n \n Your host. Your keys. Your repo. Tokens bill straight to your\n Anthropic account — never proxied, never marked up.\n \n\n \n \n Explore Fragua\n \n \n \n \n \n \n\n \n \n \n\n \n \n \n \n\n \n \n \n \n\n \n \n \n \n\n \n A dedicated knowledge base for managing the context your AI tools\n consume. Curate project knowledge once, serve it to every tool via\n REST API.\n \n \n Works with Claude Code, Cursor, ChatGPT, and any tool that makes\n HTTP requests.\n \n\n \n \n Explore Recuerd0\n \n \n \n \n \n \n\n \n \n \n\n \n \n \n \n\n \n \n \n \n\n \n \n \n\n \n \n \n \n\n \n Know your daily vibe, spend without the spiral. Resto tells you\n exactly what you can spend today — no more \"can I afford this?\"\n anxiety. Kakeibo-inspired personal finance, simplified.\n \n \n Track checking accounts, credit cards, and buffers. Reflect on\n your spending. Plan ahead. All in one place.\n \n\n \n \n Explore Resto\n \n \n \n \n \n \n\n \n \n \n \n\n \n \n \n \n Need help with your Rails project?\n \n \n I'm Mario Alberto Chávez—Rails architect available for consulting,\n architecture review, AI integration, and code review.\n \n \n \n Get in Touch\n \n \n \n \n \n Visit My Blog\n \n \n \n \n \n\n \n \n \n\n \n \n \n \n Get started\n \n \n Ready to build faster?\n Start using Maquina today.\n \n \n Build robust multi-tenant Rails applications with a solid foundation and best practices built-in.\n \n \n \n Get started\n \n \n \n\n \n \n \n \n \n \n\n \n \n \n \n \n \n\n \n \n \n\n \n \n Products\n \n \n \n Recuerd0\n \n \n \n \n \n Open Source\n \n \n \n Documentation\n \n \n \n \n Generators\n \n \n \n \n Components\n \n \n \n \n All Projects\n \n \n \n \n \n Company\n \n \n \n Blog\n \n \n \n \n \n Resources\n \n \n \n GitHub\n \n \n \n \n RubyGems\n \n \n \n \n \n \n \n\n \n \n \n \n \n \n \n\n \n \n \n © 2026 Maquina.\n Mario Alberto Chávez Cárdenas" }, { "id": "open-source", "title": "Open Source Rails Tools", "collection": { "label": "pages", "name": "Posts" }, "categories": "", "tags": "", "url": "/open-source/", "content": "Recuerd0\n \n \n Documentation\n \n \n Open Source\n \n \n Blog\n \n \n\n \n mobile-nav#toggle\"\n >\n \n \n \n \n \n \n \n \n \n\n\n\n \n Open Source Rails Tools\n \n\n \n Every Maquina project is MIT licensed and built in the open. Read the\n source, open an issue, or fork it for your own apps.\n \n \n \n\n\n \n \n \n\n \n \n \n Rails Libraries\n \n\n \n 4 tools\n \n \n\n \n Drop-in gems for production Rails apps.\n \n\n \n \n \n \n \n Maquina Generators\n \n\n \n App Scaffolding\n \n \n\n \n Rails generators that produce standalone application code. Authentication, job queues, error tracking, and security — no runtime dependency. Generate once, own forever.\n \n \n\n \n \n Docs\n \n\n \n \n \n \n GitHub\n \n \n \n \n \n \n \n Maquina Components\n \n\n \n UI Library\n \n \n\n \n Modern UI components for Ruby on Rails. ERB partials styled with Tailwind CSS 4.0 and Stimulus controllers. Inspired by shadcn/ui, built for the Rails way.\n \n \n\n \n \n Docs\n \n\n \n \n \n \n GitHub\n \n \n \n \n \n \n \n Maquina Newsletters\n \n\n \n Rails Engine\n \n \n\n \n A mountable Rails 8 engine for drafting, approving, scheduling, and batch-sending HTML newsletters. Action Text editing, an approval workflow, and background batch delivery.\n \n \n\n \n \n Docs\n \n\n \n \n \n \n GitHub\n \n \n \n \n \n \n \n Nexo\n \n\n \n Agent Harness\n \n \n\n \n The connective tissue linking RubyLLM to tools, sandboxes, skills, and runs. Build a working agent in five lines with safe defaults — virtual sandbox and read-only until you explicitly opt in.\n \n \n\n \n \n Docs\n \n\n \n \n \n \n GitHub\n \n \n \n \n \n\n \n \n \n AI & Editor Tooling\n \n\n \n 3 tools\n \n \n\n \n Connect Rails to LLMs, your editor, and Claude Code.\n \n\n \n \n \n \n \n Rails Claude Code\n \n\n \n Claude Code Plugins\n \n \n\n \n A marketplace of Claude Code plugins for Rails — code simplification, Rails upgrades, UI standards, MVP planning, Stimulus best practices, spec-driven development, security audits, and a Hotwire dev-server driver.\n \n \n\n \n \n Docs\n \n\n \n \n \n \n GitHub\n \n \n \n \n \n \n \n Rails MCP Server\n \n\n \n MCP Server\n \n \n\n \n A Model Context Protocol server that lets LLMs interact with Rails projects. Analyze models, routes, schemas, and execute read-only Ruby code in your Rails context.\n \n \n\n \n \n Docs\n \n\n \n \n \n \n GitHub\n \n \n \n \n \n \n \n Neovim MCP Server\n \n\n \n MCP Server\n \n \n\n \n MCP server for Neovim integration. Read and update buffers, coordinate file changes across your editor and AI assistants.\n \n \n\n \n \n Docs\n \n\n \n \n \n \n GitHub\n \n \n \n \n \n\n \n \n \n Developer Tools\n \n\n \n 4 tools\n \n \n\n \n Standalone utilities for your local workflow.\n \n\n \n \n \n \n \n equipr\n \n\n \n CLI\n \n \n\n \n Installs skills, commands, and MCP servers from marketplaces and Agent Plugins sources into Claude Code, Codex, OpenCode, and Pi, without registering as a plugin or rewriting your agent's config.\n \n \n\n \n \n Docs\n \n\n \n \n \n \n GitHub\n \n \n \n \n \n \n \n Git Continuity\n \n\n \n CLI\n \n \n\n \n Seamlessly transfer work-in-progress between machines without committing to git history. Perfect for moving unfinished work between office and home.\n \n \n\n \n \n Docs\n \n\n \n \n \n \n GitHub\n \n \n \n \n \n \n \n Redis Menu\n \n\n \n macOS App\n \n \n\n \n A macOS menu bar application for managing local Redis instances. Start, stop, and monitor Redis without touching the terminal.\n \n \n\n \n \n Docs\n \n\n \n \n \n \n GitHub\n \n \n \n \n \n \n \n Mongo Menu\n \n\n \n macOS App\n \n \n\n \n A macOS menu bar application for managing local MongoDB instances. Simple controls for your development database.\n \n \n\n \n \n Docs\n \n\n \n \n \n \n GitHub\n \n \n \n \n \n \n \n \n\n \n \n \n \n \n Contribute\n \n\n \n Code, docs, bug reports, feature requests — all welcome. Open a\n pull request or start a discussion on GitHub.\n \n\n \n \n View on GitHub\n \n \n \n\n \n \n Built for simplicity\n \n\n \n Every tool starts in a real production application. No build\n pipelines, no framework fatigue. Just Rails the way it was meant to\n be.\n \n\n \n \n Read Documentation\n \n \n \n \n \n \n\n \n \n \n\n \n \n Need help with your Rails project?\n \n \n I'm Mario Alberto Chávez—Rails architect available for consulting, AI integration, and code review.\n \n \n \n Get in Touch\n \n \n \n \n \n My Personal Website\n \n \n \n\n\n \n \n\n \n \n \n\n \n \n \n \n \n \n \n \n \n\n \n \n \n\n \n \n Products\n \n \n \n Recuerd0 \n \n \n \n \n \n Open Source\n \n \n \n Documentation \n \n \n \n \n Generators \n \n \n \n \n Components \n \n \n \n \n All Projects \n \n \n \n \n \n Company\n \n \n \n Blog \n \n \n \n \n \n Resources\n \n \n \n GitHub \n \n \n \n \n RubyGems \n \n \n \n \n \n \n \n\n \n \n \n \n \n \n \n\n \n \n \n © 2026 Maquina. Mario Alberto Chávez Cárdenas" }, { "id": "", "title": "Maquina", "collection": { "label": "data", "name": "Posts" }, "categories": "", "tags": "", "url": "", "content": "" } ] --- URL: https://maquina.app/open-source/ Title: Open Source Rails Tools ---

Open Source Rails Tools

Every Maquina project is MIT licensed and built in the open. Read the source, open an issue, or fork it for your own apps.

Rails Libraries

4 tools

Drop-in gems for production Rails apps.

  • Maquina Generators

    App Scaffolding

    Rails generators that produce standalone application code. Authentication, job queues, error tracking, and security — no runtime dependency. Generate once, own forever.

  • Maquina Components

    UI Library

    Modern UI components for Ruby on Rails. ERB partials styled with Tailwind CSS 4.0 and Stimulus controllers. Inspired by shadcn/ui, built for the Rails way.

  • Maquina Newsletters

    Rails Engine

    A mountable Rails 8 engine for drafting, approving, scheduling, and batch-sending HTML newsletters. Action Text editing, an approval workflow, and background batch delivery.

  • Nexo

    Agent Harness

    The connective tissue linking RubyLLM to tools, sandboxes, skills, and runs. Build a working agent in five lines with safe defaults — virtual sandbox and read-only until you explicitly opt in.

AI & Editor Tooling

3 tools

Connect Rails to LLMs, your editor, and Claude Code.

  • Rails Claude Code

    Claude Code Plugins

    A marketplace of Claude Code plugins for Rails — code simplification, Rails upgrades, UI standards, MVP planning, Stimulus best practices, spec-driven development, security audits, and a Hotwire dev-server driver.

  • Rails MCP Server

    MCP Server

    A Model Context Protocol server that lets LLMs interact with Rails projects. Analyze models, routes, schemas, and execute read-only Ruby code in your Rails context.

  • Neovim MCP Server

    MCP Server

    MCP server for Neovim integration. Read and update buffers, coordinate file changes across your editor and AI assistants.

Developer Tools

4 tools

Standalone utilities for your local workflow.

  • equipr

    CLI

    Installs skills, commands, and MCP servers from marketplaces and Agent Plugins sources into Claude Code, Codex, OpenCode, and Pi, without registering as a plugin or rewriting your agent's config.

  • Git Continuity

    CLI

    Seamlessly transfer work-in-progress between machines without committing to git history. Perfect for moving unfinished work between office and home.

  • Redis Menu

    macOS App

    A macOS menu bar application for managing local Redis instances. Start, stop, and monitor Redis without touching the terminal.

  • Mongo Menu

    macOS App

    A macOS menu bar application for managing local MongoDB instances. Simple controls for your development database.

Contribute

Code, docs, bug reports, feature requests — all welcome. Open a pull request or start a discussion on GitHub.

Built for simplicity

Every tool starts in a real production application. No build pipelines, no framework fatigue. Just Rails the way it was meant to be.

Need help with your Rails project?

I'm Mario Alberto Chávez—Rails architect available for consulting, AI integration, and code review.

--- URL: https://maquina.app/404 Title: Page Not Found - Maquina ---

404

Page not found

Sorry, we couldn't find the page you're looking for.

--- URL: https://maquina.app/500 Title: Server Error - Maquina ---

500

Something went wrong

We're experiencing technical difficulties. Please try again later.

--- URL: https://maquina.app/company/ Title: About Maquina — Open Source Rails Tools & Philosophy ---

Empowering Rails developers everywhere.

We're building the tools that make multi-tenant Rails applications accessible to every developer.

Our mission

At Maquina, we believe that building multi-tenant applications shouldn't require months of boilerplate code or expensive SaaS subscriptions. Every Rails developer should have access to professional-grade tools for building modern applications.

Maquina was born from years of experience building production Rails applications. We've distilled the best patterns and practices into a single, cohesive framework that gets out of your way and lets you focus on what makes your application unique.

The Numbers


License
MIT
Open Source
100%
Rails Version
7+
Ruby Version
3.2+

Our Story

Built by developers, for developers.

Maquina is the result of years of building Rails applications and learning what patterns work best.

After building dozens of multi-tenant Rails applications, we noticed the same patterns emerging over and over: authentication flows, organization management, role-based access control, and UI components that needed to be rebuilt for every project.

We decided to distill these patterns into a single, cohesive framework. Maquina follows Rails conventions, embraces Hotwire for modern interactivity without JavaScript complexity, and provides beautiful UI components built with ViewComponent and Tailwind CSS.

Open Source on GitHub

Our Values

Principles that guide us.

Everything we build is guided by these core principles.

Core Principles


  • Convention over configuration

    Follow Rails conventions whenever possible. Sensible defaults mean less code to write and maintain. We believe the best code is the code you don't have to write.

  • Security first

    Security is not an afterthought. Every feature is designed with security best practices from the start. Authentication, authorization, and data isolation are core to the framework.

  • Documentation matters

    Great software deserves great documentation. We invest heavily in guides, examples, and API references. If it's not documented, it doesn't exist.

  • Community driven

    Built by the community, for the community. Every contribution matters and every voice is heard. We're committed to building in the open with transparency.

Get Involved

Join our open source community.

We welcome contributions of all kinds. Whether it's code, documentation, or feedback, every contribution helps make Maquina better for everyone.

Ways to contribute

Contribution type Link
Code
Submit a pull request View
Report a bug View
Community
Join the discussion View
Star on GitHub View

Open source is not just about code. It's about building a community of developers who share knowledge and help each other grow.

The Maquina Team

Open Source Contributors