Rails Integration

Rails + AI made simple. Persist chats with ActiveRecord. Stream with Hotwire. Deploy with confidence.

Table of contents

  1. Understanding the Persistence Flow
    1. How It Works
    2. Why This Design?
    3. Content Validation Implications
  2. Setting Up Your Rails Application
    1. Quick Setup with Generator
    2. Adding a Chat UI
      1. Generator Options
    3. Setting Up ActiveStorage
    4. Configuring RubyLLM
    5. Setting Up Models with acts_as Helpers
      1. With Model Registry (Default for new apps)
      2. Legacy Mode (Without Model Registry)
    6. Provider Overrides
    7. Custom Contexts and Dynamic Models
      1. Using Custom Contexts
      2. Dynamic Model Creation
  3. Working with Chats
    1. Basic Chat Operations
    2. Database Model Registry
    3. System Instructions
    4. Using Tools
    5. File Attachments
    6. Structured Output
  4. Advanced Topics
    1. Handling Edge Cases
      1. Automatic Cleanup
      2. Provider Content Restrictions
    2. Customizing the Persistence Flow
  5. Streaming Responses with Hotwire/Turbo
    1. Instant User Messages
    2. Full Streaming Implementation
    3. Message Ordering Issues
      1. Solution 1: Client-Side Reordering (Recommended)
      2. Solution 2: Server-Side Ordering
      3. Why This Happens
  6. Customizing Models
    1. Using Custom Model Names
      1. With Model Registry
      2. Namespaced Models Example
      3. Legacy Mode
    2. Common Customizations
  7. Next Steps

After reading this guide, you will know:

  • How to set up ActiveRecord models for persisting chats and messages
  • How the RubyLLM persistence flow works with Rails applications
  • How to use acts_as_chat and acts_as_message with your models
  • How to persist AI model metadata in your database with acts_as_model
  • How to send file attachments to AI models with ActiveStorage
  • How to integrate streaming responses with Hotwire/Turbo Streams
  • How to customize the persistence behavior for validation-focused scenarios

Understanding the Persistence Flow

Before diving into setup, it’s important to understand how RubyLLM handles message persistence in Rails. This design influences model validations and real-time UI updates.

How It Works

When calling chat_record.ask("What is the capital of France?"), RubyLLM:

  1. Saves the user message with the question content
  2. Calls the complete method, which:
    • Makes the API call to the AI provider
    • Creates an empty assistant message:
      • With streaming: On receiving the first chunk
      • Without streaming: Before the API call
    • Processes the response:
      • Success: Updates the assistant message with content and metadata
      • Failure: Automatically destroys the empty assistant message

Why This Design?

This approach optimizes for real-time experiences:

  1. Streaming optimized: Creates DOM target on first chunk for immediate UI updates
  2. Turbo Streams ready: Works with after_create_commit for real-time broadcasting
  3. Clean rollback: Automatic cleanup on failure prevents orphaned records

Content Validation Implications

Important: You cannot use validates :content, presence: true on your Message model. See Customizing the Persistence Flow for an alternative approach.

Setting Up Your Rails Application

Quick Setup with Generator

The easiest way to get started is using the provided Rails generator:

rails generate ruby_llm:install

The generator:

  • Creates migrations for Chat, Message, ToolCall, and Model tables
  • Sets up model files with appropriate acts_as declarations
  • Installs ActiveStorage for file attachments
  • Configures the database model registry
  • Creates an initializer with sensible defaults

After running the generator:

rails db:migrate

Your Rails app is now AI-ready!

Adding a Chat UI

Want a ready-to-use chat interface? Run the chat UI generator:

rails generate ruby_llm:chat_ui

This creates a complete chat interface with:

  • Controllers: Handles chat and message creation with background processing
  • Views: Modern UI with Turbo Streams for real-time updates
  • Jobs: Background job for processing AI responses without blocking
  • Routes: RESTful routes for chats and messages

After running the generator, start your server and visit http://localhost:3000/chats to begin chatting!

The UI generator also supports custom model names:

# Use your custom model names from the install generator
rails generate ruby_llm:chat_ui chat:Conversation message:ChatMessage model:AIModel

Generator Options

The generator uses Rails-like syntax for custom model names:

# Default - creates Chat, Message, ToolCall, Model
rails generate ruby_llm:install

# Custom model names using Rails conventions
rails generate ruby_llm:install chat:Conversation message:ChatMessage
rails generate ruby_llm:install chat:Discussion message:DiscussionMessage tool_call:FunctionCall model:AIModel

# Skip ActiveStorage if you don't need file attachments
rails generate ruby_llm:install --skip-active-storage

The name:ClassName syntax follows Rails conventions - specify only what you want to customize.

Setting Up ActiveStorage

The generator automatically configures ActiveStorage for file attachments. If you skipped it during generation, add it manually:

rails active_storage:install
rails db:migrate

Then add to your Message model:

# app/models/message.rb
class Message < ApplicationRecord
  acts_as_message
  has_many_attached :attachments  # Required for file attachments
end

Configuring RubyLLM

Set up your API keys and other configuration in the initializer:

# config/initializers/ruby_llm.rb
RubyLLM.configure do |config|
  config.openai_api_key = ENV['OPENAI_API_KEY']
  config.anthropic_api_key = ENV['ANTHROPIC_API_KEY']
  config.gemini_api_key = ENV['GEMINI_API_KEY']

  # New apps: Use modern API (generator adds this)
  config.use_new_acts_as = true

  # For custom Model class names (defaults to 'Model')
  # config.model_registry_class = 'AIModel'
end

Setting Up Models with acts_as Helpers

New in v1.7.0: Rails-like acts_as API with association names!

  • New apps: Generator sets config.use_new_acts_as = true for modern API
  • Existing apps: Continue using legacy API (with deprecation warning)
  • Migrate today: Set config.use_new_acts_as = true to use the better API
  • Legacy API removed in 2.0: The new API will become the only option

Add RubyLLM capabilities to your models:

With Model Registry (Default for new apps)

Available in v1.7.0+

# app/models/chat.rb
class Chat < ApplicationRecord
  # New API style - uses association names as primary parameters
  acts_as_chat # Defaults: messages: :messages, model: :model

  # Or with custom associations:
  # acts_as_chat messages: :chat_messages,
  #              message_class: 'ChatMessage',  # Only needed if class can't be inferred
  #              model: :ai_model

  belongs_to :user, optional: true
end

# app/models/message.rb
class Message < ApplicationRecord
  # New API style - uses association names
  acts_as_message # Defaults: chat: :chat, tool_calls: :tool_calls, model: :model

  # Or with custom associations:
  # acts_as_message chat: :conversation,
  #                 chat_class: 'Conversation',  # Only needed if class can't be inferred
  #                 tool_calls: :function_calls

  # Note: Do NOT add "validates :content, presence: true"
  validates :role, presence: true
  validates :chat, presence: true
end

# app/models/tool_call.rb
class ToolCall < ApplicationRecord
  acts_as_tool_call # Defaults: message: :message, result: :result
end

# app/models/model.rb
class Model < ApplicationRecord
  acts_as_model # Defaults: chats: :chats
end

Legacy Mode (Without Model Registry)

Pre-1.7.0 or opt-in

Default behavior for existing apps. Set config.use_new_acts_as = true to upgrade! Legacy API will be removed in 2.0.

# app/models/chat.rb
class Chat < ApplicationRecord
  # Legacy API style - requires explicit class names
  acts_as_chat message_class: 'Message',
               tool_call_class: 'ToolCall',
               model_class: 'Model'  # Ignored in legacy mode
end

# app/models/message.rb
class Message < ApplicationRecord
  # Legacy API style - all class names and foreign keys explicit
  acts_as_message chat_class: 'Chat',
                  chat_foreign_key: 'chat_id',
                  tool_call_class: 'ToolCall',
                  model_class: 'Model'  # Ignored in legacy mode
end

# app/models/tool_call.rb
class ToolCall < ApplicationRecord
  acts_as_tool_call message_class: 'Message',
                    message_foreign_key: 'message_id'
end

# Note: No Model class in legacy mode - uses string fields instead

Provider Overrides

Available in v1.7.0+

Route models through different providers dynamically:

# Use a model through a different provider
chat = Chat.create!(
  model: 'claude-sonnet-4',
  provider: 'bedrock'  # Use AWS Bedrock instead of Anthropic
)

# The model registry handles the routing automatically
chat.ask("Hello!")

Custom Contexts and Dynamic Models

Available in v1.7.0+

Using Custom Contexts

Use different API keys per chat in multi-tenant applications:

With DB-backed model registry (default in v1.7.0+):

# Create a custom context
custom_context = RubyLLM.context do |config|
  config.openai_api_key = 'sk-customer-specific-key'
end

# Pass context when creating the chat
chat = Chat.create!(
  model: 'gpt-4.1',
  context: custom_context
)

Legacy mode (when using --skip-model-registry):

# In legacy mode, you can set context after creation
chat = Chat.create!(model: 'gpt-4')
chat.with_context(custom_context)  # This method only exists in legacy mode

Warning: Context is not persisted. Set it after reloading chats.

# Later, in a different request or after restart
chat = Chat.find(chat_id)
chat.context = custom_context  # Must set this!
chat.ask("Continue our conversation")

For multi-tenant apps, consider using an after_find callback:

class Chat < ApplicationRecord
  acts_as_chat
  belongs_to :tenant

  after_find :set_tenant_context

  private

  def set_tenant_context
    self.context = RubyLLM.context do |config|
      config.openai_api_key = tenant.openai_api_key
    end
  end
end

Dynamic Model Creation

When using models not in the registry (e.g., new OpenRouter models):

# Create chat with a dynamic model
chat = Chat.create!(
  model: 'experimental-llm-v2',
  provider: 'openrouter',
  assume_model_exists: true  # Creates Model record automatically
)

Note: Like context, assume_model_exists is not persisted.

# When switching to another dynamic model later
chat = Chat.find(chat_id)
chat.assume_model_exists = true
chat.with_model('another-experimental-model', provider: 'openrouter')

Working with Chats

Basic Chat Operations

The acts_as_chat helper provides all standard chat methods:

# Create a chat
chat_record = Chat.create!(model: 'gpt-4.1-nano', user: current_user)

# Ask a question - the persistence flow runs automatically
begin
  # This saves the user message, then calls complete() which:
  # 1. Creates an empty assistant message
  # 2. Makes the API call
  # 3. Updates the message on success, or destroys it on failure
  response = chat_record.ask "What is the capital of France?"

  # Get the persisted message record from the database
  assistant_message_record = chat_record.messages.last
  puts assistant_message_record.content # => "The capital of France is Paris."
rescue RubyLLM::Error => e
  puts "API Call Failed: #{e.message}"
  # The empty assistant message is automatically cleaned up on failure
end

# Continue the conversation
chat_record.ask "Tell me more about that city"

# Verify persistence
puts "Conversation length: #{chat_record.messages.count}" # => 4

Database Model Registry

Available in v1.7.0+

When using the Model registry (created by default by the generator), your chats and messages get associations to model records:

# String automatically resolves to Model record
chat = Chat.create!(model: 'gpt-4.1')
chat.model # => #<Model model_id: "gpt-4o", provider: "openai">
chat.model.name # => "GPT-4"
chat.model.context_window # => 128000
chat.model.supports_vision # => true

# Populate/refresh models from models.json
rails ruby_llm:load_models

# Query based on model attributes
Chat.joins(:model).where(models: { provider: 'anthropic' })
Model.left_joins(:chats).group(:id).order('COUNT(chats.id) DESC')

# Find models with specific capabilities
Model.where(supports_functions: true)
Model.where(supports_vision: true)

System Instructions

System prompts are persisted as messages with the system role:

chat_record = Chat.create!(model: 'gpt-4.1-nano')

# This creates and saves a Message record with role: :system
chat_record.with_instructions("You are a Ruby expert.")

# Replace all system messages with a new one
chat_record.with_instructions("You are a concise Ruby expert.", replace: true)

system_message = chat_record.messages.find_by(role: :system)
puts system_message.content # => "You are a concise Ruby expert."

Using Tools

Tools are Ruby classes that the AI can call. While the tool classes themselves aren’t persisted, the tool calls and their results are saved as messages:

# Define a tool (this is just a Ruby class, not persisted)
class Weather < RubyLLM::Tool
  description "Gets current weather for a location"
  param :city, desc: "City name"

  def execute(city:)
    "The weather in #{city} is sunny and 22°C."
  end
end

# Register the tool with your chat
chat_record = Chat.create!(model: 'gpt-4.1-nano')
chat_record.with_tool(Weather)

# When the AI uses the tool, both the call and result are persisted
response = chat_record.ask("What's the weather in Paris?")

# Check persisted messages:
# 1. User message: "What's the weather in Paris?"
# 2. Assistant message with tool_calls (the AI's decision to use the tool)
# 3. Tool result message (the output from Weather#execute)
puts chat_record.messages.count # => 3

# The tool call details are stored in the ToolCall table
tool_call = chat_record.messages.second.tool_calls.first
puts tool_call.name # => "Weather"
puts tool_call.arguments # => {"city" => "Paris"}

File Attachments

Send files to AI models using ActiveStorage:

# Create a chat
chat_record = Chat.create!(model: 'claude-sonnet-4')

# Send a single file - type automatically detected
chat_record.ask("What's in this file?", with: "app/assets/images/diagram.png")

# Send multiple files of different types - all automatically detected
chat_record.ask("What are in these files?", with: [
  "app/assets/documents/report.pdf",
  "app/assets/images/chart.jpg",
  "app/assets/text/notes.txt",
  "app/assets/audio/recording.mp3"
])

# Works with file uploads from forms
chat_record.ask("Analyze this file", with: params[:uploaded_file])

# Works with existing ActiveStorage attachments
chat_record.ask("What's in this document?", with: user.profile_document)

File types are automatically detected from extensions or MIME types.

Structured Output

Generate and persist structured responses:

# Define a schema
class PersonSchema < RubyLLM::Schema
  string :name
  integer :age
  string :city, required: false
end

# Use with your persisted chat
chat_record = Chat.create!(model: 'gpt-4.1-nano')
response = chat_record.with_schema(PersonSchema).ask("Generate a person from Paris")

# The structured response is automatically parsed as a Hash
puts response.content # => {"name" => "Marie", "age" => 28, "city" => "Paris"}

# But it's stored as JSON in the database
message = chat_record.messages.last
puts message.content # => "{\"name\":\"Marie\",\"age\":28,\"city\":\"Paris\"}"
puts JSON.parse(message.content) # => {"name" => "Marie", "age" => 28, "city" => "Paris"}

Schemas work in multi-turn conversations:

# Start with a schema
chat_record.with_schema(PersonSchema)
person = chat_record.ask("Generate a French person")

# Remove the schema for analysis
chat_record.with_schema(nil)
analysis = chat_record.ask("What's interesting about this person?")

# All messages are persisted correctly
puts chat_record.messages.count # => 4

Advanced Topics

Handling Edge Cases

Automatic Cleanup

RubyLLM automatically cleans up empty assistant messages when API calls fail. This prevents orphaned records that could cause issues with providers that reject empty content.

Provider Content Restrictions

Some providers (like Gemini) reject conversations with empty message content. RubyLLM’s automatic cleanup ensures this isn’t an issue during normal operation.

Customizing the Persistence Flow

For applications requiring content validations, override the default persistence methods:

# app/models/chat.rb
class Chat < ApplicationRecord
  acts_as_chat

  # Override the default persistence methods
  private

  def persist_new_message
    # Create a new message object but don't save it yet
    @message = messages.new(role: :assistant)
  end

  def persist_message_completion(message)
    return unless message

    # Fill in attributes and save once we have content
    @message.assign_attributes(
      content: message.content,
      model: Model.find_by(model_id: message.model_id),
      input_tokens: message.input_tokens,
      output_tokens: message.output_tokens
    )

    @message.save!

    # Handle tool calls if present
    persist_tool_calls(message.tool_calls) if message.tool_calls.present?
  end

  def persist_tool_calls(tool_calls)
    tool_calls.each_value do |tool_call|
      attributes = tool_call.to_h
      attributes[:tool_call_id] = attributes.delete(:id)
      @message.tool_calls.create!(**attributes)
    end
  end
end

# app/models/message.rb
class Message < ApplicationRecord
  acts_as_message

  # Now you can safely add this validation
  validates :content, presence: true
end

This approach trades streaming UI updates for content validation support:

  • ✅ Content validations work
  • ✅ No empty messages in database
  • ❌ No DOM target for streaming before API response

Streaming Responses with Hotwire/Turbo

The default persistence flow is designed to work seamlessly with streaming and Turbo Streams for real-time UI updates.

Instant User Messages

Show user messages immediately for better UX:

# app/controllers/messages_controller.rb
class MessagesController < ApplicationController
  def create
    @chat = Chat.find(params[:chat_id])

    # Create and persist the user message immediately
    @chat.create_user_message(params[:content])

    # Process AI response in background
    ChatStreamJob.perform_later(@chat.id)

    respond_to do |format|
      format.turbo_stream { head :ok }
      format.html { redirect_to @chat }
    end
  end
end

The create_user_message method provides instant feedback while processing continues in the background.

Full Streaming Implementation

Complete example with background jobs and Turbo Streams:

# app/models/chat.rb
class Chat < ApplicationRecord
  acts_as_chat
  broadcasts_to ->(chat) { [chat, "messages"] }
end

# app/models/message.rb
class Message < ApplicationRecord
  acts_as_message
  broadcasts_to ->(message) { [message.chat, "messages"] }

  # Helper to broadcast chunks during streaming
  def broadcast_append_chunk(chunk_content)
    broadcast_append_to [ chat, "messages" ], # Target the stream
      target: dom_id(self, "content"), # Target the content div inside the message frame
      html: chunk_content # Append the raw chunk
  end
end

# app/jobs/chat_stream_job.rb
class ChatStreamJob < ApplicationJob
  queue_as :default

  def perform(chat_id)
    chat = Chat.find(chat_id)

    # Process the latest user message
    chat.complete do |chunk|
      # Get the assistant message record (created before streaming starts)
      assistant_message = chat.messages.last
      if chunk.content && assistant_message
        # Append the chunk content to the message's target div
        assistant_message.broadcast_append_chunk(chunk.content)
      end
    end
    # Final assistant message is now fully persisted
  end
end
<%# app/views/chats/show.html.erb %>
<%= turbo_stream_from [@chat, "messages"] %>
<h1>Chat <%= @chat.id %></h1>
<div id="messages">
  <%= render @chat.messages %>
</div>
<!-- Your form to submit new messages -->
<%= form_with(url: chat_messages_path(@chat), method: :post) do |f| %>
  <%= f.text_area :content %>
  <%= f.submit "Send" %>
<% end %>

<%# app/views/messages/_message.html.erb %>
<%= turbo_frame_tag message do %>
  <div class="message <%= message.role %>">
    <strong><%= message.role.capitalize %>:</strong>
    <%# Target div for streaming content %>
    <div id="<%= dom_id(message, "content") %>" style="display: inline;">
      <%# Render initial content if not streaming, otherwise job appends here %>
      <%= message.content.present? ? simple_format(message.content) : '<span class="thinking">...</span>'.html_safe %>
    </div>
  </div>
<% end %>

This implementation provides:

  • Real-time UI updates during generation
  • Background processing to prevent timeouts
  • Automatic persistence of all messages and tool calls

Message Ordering Issues

Action Cable processes messages concurrently, which can cause out-of-order delivery:

Use Stimulus to maintain chronological order:

// app/javascript/controllers/message_ordering_controller.js
// Note: This is an example implementation. Test thoroughly before production use.
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["message"]

  connect() {
    this.reorderMessages()
    this.observeNewMessages()
  }

  observeNewMessages() {
    // Watch for new messages being added to the DOM
    const observer = new MutationObserver((mutations) => {
      let shouldReorder = false

      mutations.forEach((mutation) => {
        mutation.addedNodes.forEach((node) => {
          if (node.nodeType === 1 && node.matches('[data-message-ordering-target="message"]')) {
            shouldReorder = true
          }
        })
      })

      if (shouldReorder) {
        // Small delay to ensure all attributes are set
        setTimeout(() => this.reorderMessages(), 10)
      }
    })

    observer.observe(this.element, { childList: true, subtree: true })
    this.observer = observer
  }

  disconnect() {
    if (this.observer) {
      this.observer.disconnect()
    }
  }

  reorderMessages() {
    const messages = Array.from(this.messageTargets)

    // Sort by timestamp (created_at)
    messages.sort((a, b) => {
      const timeA = new Date(a.dataset.createdAt).getTime()
      const timeB = new Date(b.dataset.createdAt).getTime()
      return timeA - timeB
    })

    // Reorder in DOM
    messages.forEach((message) => {
      this.element.appendChild(message)
    })
  }
}

Update your views to use the controller:

<%# app/views/chats/show.html.erb %>
<!-- Add the Stimulus controller to the messages container -->
<div id="messages" data-controller="message-ordering">
  <%= render @chat.messages %>
</div>

<%# app/views/messages/_message.html.erb %>
<%= turbo_frame_tag message,
    data: {
      message_ordering_target: "message",
      created_at: message.created_at.iso8601
    } do %>
  <!-- message content -->
<% end %>

Solution 2: Server-Side Ordering

AnyCable provides order guarantees at the server level through “sticky concurrency” - ensuring messages from the same stream are processed by the same worker. This eliminates the need for client-side reordering code.

Why This Happens

Action Cable uses concurrent processing by design for performance.

For strict ordering requirements, consider:

  • Server-sent events (SSE) for unidirectional streaming
  • WebSocket libraries with ordered stream support like Lively
  • AnyCable for server-side ordering guarantees

Note: The async Ruby stack (Falcon + async-cable) may improve behavior but doesn’t guarantee ordering.

Customizing Models

The acts_as helpers integrate seamlessly with standard Rails patterns. Add associations, validations, scopes, and callbacks as needed.

Using Custom Model Names

If your application uses different model names, you can configure the acts_as helpers accordingly:

With Model Registry

Available in v1.7.0+

# app/models/conversation.rb (instead of Chat)
class Conversation < ApplicationRecord
  acts_as_chat messages: :chat_messages,  # Association name
               message_class: 'ChatMessage',  # Optional if inferrable
               model: :ai_model,
               model_class: 'AiModel'  # Optional if inferrable

  belongs_to :user, optional: true
end

# app/models/chat_message.rb (instead of Message)
class ChatMessage < ApplicationRecord
  acts_as_message chat: :conversation,  # Association name
                  chat_class: 'Conversation',  # Optional if inferrable
                  tool_calls: :ai_tool_calls,
                  tool_call_class: 'AIToolCall',  # Required for non-standard naming
                  model: :ai_model
end

# app/models/ai_tool_call.rb (instead of ToolCall)
class AIToolCall < ApplicationRecord
  acts_as_tool_call message: :chat_message,
                    message_class: 'ChatMessage',  # Optional if inferrable
                    result: :result
end

# app/models/ai_model.rb (instead of Model)
class AiModel < ApplicationRecord
  acts_as_model chats: :conversations,
                chat_class: 'Conversation'  # Optional if inferrable
end

Namespaced Models Example

For namespaced models, you’ll need to specify class names explicitly:

# app/models/admin/bot_chat.rb
module Admin
  class BotChat < ApplicationRecord
    acts_as_chat messages: :bot_messages,
                 message_class: 'Admin::BotMessage'  # Required for namespace
  end
end

# app/models/admin/bot_message.rb
module Admin
  class BotMessage < ApplicationRecord
    acts_as_message chat: :bot_chat,
                    chat_class: 'Admin::BotChat'  # Required for namespace
  end
end

Legacy Mode

Pre-1.7.0 or opt-in

# app/models/conversation.rb
class Conversation < ApplicationRecord
  acts_as_chat message_class: 'ChatMessage',
               tool_call_class: 'AIToolCall'
end

# app/models/chat_message.rb
class ChatMessage < ApplicationRecord
  acts_as_message chat_class: 'Conversation',
                  chat_foreign_key: 'conversation_id',
                  tool_call_class: 'AIToolCall'
end

# app/models/ai_tool_call.rb
class AIToolCall < ApplicationRecord
  acts_as_tool_call message_class: 'ChatMessage',
                    message_foreign_key: 'chat_message_id'
end

Common Customizations

Extend your models with standard Rails patterns:

# app/models/chat.rb
class Chat < ApplicationRecord
  acts_as_chat

  # Add typical Rails associations
  belongs_to :user
  has_many :favorites, dependent: :destroy

  # Add scopes
  scope :recent, -> { order(updated_at: :desc) }
  scope :with_responses, -> { joins(:messages).where(messages: { role: 'assistant' }).distinct }

  # Add custom methods
  def summary
    messages.last(2).map(&:content).join(' ... ')
  end

  # Add callbacks
  after_create :notify_administrators

  private

  def notify_administrators
    # Custom logic
  end
end

Next Steps


Brought to you by Carmine Paolino, maker of — Claude Code for your documents