Skip to main content
Complete API reference for Forest Ruby agent packages.

Agent Setup

Creating an Agent

The Ruby agent is designed for Rails applications and automatically introspects your data models.
# Gemfile
gem 'forest_admin_agent'
gem 'forest_admin_rails'
gem 'forest_admin_datasource_toolkit'
gem 'forest_admin_datasource_customizer'
gem 'forest_admin_datasource_active_record' # For ActiveRecord
# or
gem 'forest_admin_datasource_mongoid' # For Mongoid
Installation:
bundle install
rails generate forest_admin_rails:install
The generator creates two files:
  • config/initializers/forest_admin_rails.rb, secrets and configuration
  • app/lib/forest_admin_rails/create_agent.rb, datasource setup and collection customizations
Configuration (config/initializers/forest_admin_rails.rb):
ForestAdminRails.configure do |config|
  config.auth_secret = ENV.fetch('FOREST_AUTH_SECRET')
  config.env_secret = ENV.fetch('FOREST_ENV_SECRET')
end
Datasource setup and customizations (app/lib/forest_admin_rails/create_agent.rb):
module ForestAdminRails
  class CreateAgent
    def self.setup!
      database_configuration = Rails.configuration.database_configuration
      datasource = ForestAdminDatasourceActiveRecord::Datasource.new(database_configuration[Rails.env])

      @create_agent = ForestAdminAgent::Builder::AgentFactory.instance.add_datasource(datasource)
      customize
      @create_agent.build
    end

    def self.customize
      # All your collection customizations go here, see below.
    end
  end
end
Configuration Options (passed to ForestAdminRails.configure):
OptionTypeRequiredDescription
auth_secretStringYesYour FOREST_AUTH_SECRET
env_secretStringYesYour FOREST_ENV_SECRET
forest_server_urlStringNoForest server URL (default: production)

Customizing Collections

agent.customize_collection(name, &block)

Customize a specific collection with the provided block.
# Inside ForestAdminRails::CreateAgent.customize
@create_agent.customize_collection('User') do |collection|
  collection.add_action('Send email', {
    scope: 'Single',
    execute: ->(context, result_builder) {
      # Action logic
      result_builder.success('Email sent!')
    }
  })
end
Parameters:
ParameterTypeDescription
nameStringCollection name
blockBlockCustomization block

Datasources

agent.add_datasource(datasource, options = )

Add a datasource to the agent. Called inside ForestAdminRails::CreateAgent.setup!.
# Inside ForestAdminRails::CreateAgent.setup!
@create_agent = ForestAdminAgent::Builder::AgentFactory.instance.add_datasource(
  ForestAdminDatasourceActiveRecord::Datasource.new(database_configuration[Rails.env]),
  exclude: ['internal_logs']
)
Options:
OptionTypeDescription
includeArray<String>Collections to include
excludeArray<String>Collections to exclude
renameHashRename collections
Example with Mongoid:
# Inside ForestAdminRails::CreateAgent.setup!
@create_agent = ForestAdminAgent::Builder::AgentFactory.instance.add_datasource(
  ForestAdminDatasourceMongoid::Datasource.new,
  rename: { 'old_name' => 'new_name' }
)

Actions

collection.add_action(name, definition)

Add an action to the collection.
collection.add_action('Send email', {
  scope: 'Single',
  execute: ->(context, result_builder) {
    user = context.get_record(['id', 'email'])
    UserMailer.notification(user['email']).deliver_later
    result_builder.success('Email sent!')
  }
})
Definition Properties:
PropertyTypeDescription
scopeSymbolAction scope: :Single, :Bulk, or :Global
executeProcAction execution handler
formArrayDynamic form configuration
descriptionStringAction description
generate_fileBooleanWhether action returns a file
submit_button_labelStringCustom button text
Execute Block:
execute: ->(context, result_builder) {
  # Action logic
}
ActionContext Methods:
  • context.collection - Collection instance
  • context.filter - Filter for selected records
  • context.caller - User who triggered the action
  • context.form_values - Form values submitted
  • context.get_records(fields) - Get multiple records
  • context.get_record(fields) - Get single record (Single scope)
  • context.get_record_ids - Get IDs of selected records
  • context.has_field_changed(field_name) - Check if form field changed
ResultBuilder Methods:
  • result_builder.success(message, options = {}) - Success response
    • options[:html] - Custom HTML to display
    • options[:invalidated] - Array of collection names to refresh
  • result_builder.error(message, options = {}) - Error response
  • result_builder.webhook(url, method, headers, body) - Trigger webhook
  • result_builder.file(stream, filename, mime_type) - Return file download
  • result_builder.redirect_to(path) - Redirect to URL
  • result_builder.set_header(name, value) - Add HTTP header
Example - Action with Form:
collection.add_action('Send notification', {
  scope: 'Bulk',
  form: [
    {
      label: 'Message',
      type: 'String',
      is_required: true
    },
    {
      label: 'Channel',
      type: 'Enum',
      enum_values: ['email', 'sms', 'push'],
      is_required: true
    }
  ],
  execute: ->(context, result_builder) {
    message = context.form_values['message']
    channel = context.form_values['channel']
    users = context.get_records(['email'])

    users.each do |user|
      NotificationService.send(user['email'], message, channel)
    end

    result_builder.success("Sent #{channel} to #{users.length} users")
  }
})
Example - File Generation:
collection.add_action('Export to CSV', {
  scope: 'Bulk',
  generate_file: true,
  execute: ->(context, result_builder) {
    records = context.get_records(['name', 'email'])
    csv_stream = CsvGenerator.generate(records)

    result_builder.file(csv_stream, 'export.csv', 'text/csv')
  }
})

Fields

collection.add_field(name, definition)

Add a computed field to the collection.
collection.add_field('full_name', {
  column_type: 'String',
  dependencies: ['first_name', 'last_name'],
  get_values: ->(records) {
    records.map { |r| "#{r['first_name']} #{r['last_name']}" }
  }
})
Definition Properties:
PropertyTypeRequiredDescription
column_typeStringYesField data type
dependenciesArrayYesFields needed for computation
get_valuesProcYesValue computation function
default_valueAnyNoDefault value
enum_valuesArrayNoEnum options (if type is Enum)
Column Types:
  • 'String' - Text
  • 'Number' - Numeric value
  • 'Boolean' - True/false
  • 'Date' - Date with time
  • 'Dateonly' - Date without time
  • 'Enum' - Enumeration
  • 'Json' - JSON object
  • 'Uuid' - UUID
Example - Async Computed Field:
collection.add_field('revenue_this_year', {
  column_type: 'Number',
  dependencies: ['id'],
  get_values: ->(records) {
    ids = records.map { |r| r['id'] }
    RevenueCalculator.fetch_for_ids(ids)
  }
})

collection.import_field(name, options)

Import a field from a related collection.
# Import author's name into books collection
collection.import_field('author_name', {
  path: 'author:full_name',
  readonly: true
})
Options:
OptionTypeDescription
pathStringRelationship path (e.g., ‘author:full_name’)
readonlyBooleanWhether field is read-only

collection.rename_field(current_name, new_name)

Rename a field in the exported schema.
collection.rename_field('created_at', 'createdAt')

collection.remove_field(*names)

Remove fields from the exported schema.
collection.remove_field('password', 'internal_notes', 'debug_data')

collection.replace_field_writing(name, definition)

Replace the write behavior of a field.
# Write full_name as first_name + last_name
collection.replace_field_writing('full_name', ->(full_name) {
  parts = full_name.split(' ', 2)
  { 'first_name' => parts[0], 'last_name' => parts[1] }
})

Segments

collection.add_segment(name, definition)

Add a segment (saved filter) to the collection.
collection.add_segment('Premium users', {
  field: 'plan',
  operator: 'Equal',
  value: 'premium'
})
Example - Static Segment:
collection.add_segment('Active users', {
  field: 'status',
  operator: 'Equal',
  value: 'active'
})
Example - Dynamic Segment:
collection.add_segment('Active this month', ->(context) {
  start_of_month = Date.today.beginning_of_month

  {
    field: 'last_active_at',
    operator: 'After',
    value: start_of_month
  }
})
Example - Complex Segment:
collection.add_segment('VIP customers', ->(context) {
  {
    aggregator: 'And',
    conditions: [
      { field: 'status', operator: 'Equal', value: 'active' },
      { field: 'lifetime_value', operator: 'GreaterThan', value: 10000 }
    ]
  }
})

Relationships

collection.add_many_to_one_relation(name, foreign_collection, options)

Add a many-to-one relationship.
# books.author_id → persons.id
collection.add_many_to_one_relation('author', 'Person', {
  foreign_key: 'author_id'
})
Options:
OptionTypeDescription
foreign_keyStringForeign key field name
foreign_key_targetStringTarget field (default: ‘id’)

collection.add_one_to_many_relation(name, foreign_collection, options)

Add a one-to-many relationship.
# persons.id ← books.author_id
collection.add_one_to_many_relation('written_books', 'Book', {
  origin_key: 'author_id'
})
Options:
OptionTypeDescription
origin_keyStringForeign key in related collection
origin_key_targetStringTarget field (default: ‘id’)

collection.add_one_to_one_relation(name, foreign_collection, options)

Add a one-to-one relationship.
# persons.id ← profiles.person_id (unique)
collection.add_one_to_one_relation('profile', 'Profile', {
  origin_key: 'person_id'
})

collection.add_many_to_many_relation(name, foreign_collection, through_collection, options)

Add a many-to-many relationship.
# students ↔ student_courses ↔ courses
collection.add_many_to_many_relation('enrolled_courses', 'Course', 'StudentCourse', {
  origin_key: 'student_id',
  foreign_key: 'course_id'
})
Options:
OptionTypeDescription
origin_keyStringForeign key to origin collection
foreign_keyStringForeign key to target collection
origin_key_targetStringOrigin target field
foreign_key_targetStringForeign target field

Hooks

collection.add_hook(position, type, handler)

Add a hook to execute code before or after operations.
collection.add_hook('Before', 'Create', ->(context) {
  # Validate data before creation
  if context.data['email'].nil?
    raise 'Email is required'
  end
})
Hook Types:
  • 'List' - Before/after listing records
  • 'Create' - Before/after creating records
  • 'Update' - Before/after updating records
  • 'Delete' - Before/after deleting records
  • 'Aggregate' - Before/after aggregating data
Example - Before Hook:
collection.add_hook('Before', 'Create', ->(context) {
  # Set default values
  context.data['status'] ||= 'active'
  context.data['created_by'] = context.caller.id
})
Example - After Hook:
collection.add_hook('After', 'Update', ->(context) {
  # Send notification after update
  records = context.collection.list(context.filter, ['email'])
  records.each do |record|
    NotificationService.send_update_email(record['email'])
  end
})

Charts

collection.add_chart(name, definition)

Add a chart to the collection.
collection.add_chart('total_revenue', ->(context, result_builder) {
  total = Order.sum(:total)
  result_builder.value(total)
})
Chart Types: Value Chart:
collection.add_chart('user_count', ->(context, result_builder) {
  count = User.count
  result_builder.value(count)
})
Distribution Chart:
collection.add_chart('users_by_plan', ->(context, result_builder) {
  distribution = User.group(:plan).count
  result_builder.distribution(distribution)
})
Time-based Chart:
collection.add_chart('signups_over_time', ->(context, result_builder) {
  data = User.group_by_day(:created_at).count
  result_builder.time_based('Day', data)
})
Percentage Chart:
collection.add_chart('completion_rate', ->(context, result_builder) {
  completed = Task.where(status: 'completed').count
  total = Task.count
  rate = (completed.to_f / total * 100).round(2)
  result_builder.percentage(rate)
})
Objective Chart:
collection.add_chart('sales_goal', ->(context, result_builder) {
  current = Order.sum(:total)
  target = 100_000
  result_builder.objective(current, target)
})
Leaderboard Chart:
collection.add_chart('top_sellers', ->(context, result_builder) {
  top = User.joins(:orders)
    .group('users.name')
    .sum('orders.total')
  result_builder.leaderboard(top)
})

Search & Sorting

collection.replace_search(definition)

Replace the default search behavior.
collection.replace_search(->(search_string) {
  {
    aggregator: 'Or',
    conditions: [
      { field: 'first_name', operator: 'Contains', value: search_string },
      { field: 'last_name', operator: 'Contains', value: search_string },
      { field: 'email', operator: 'Contains', value: search_string }
    ]
  }
})

Disable search functionality on the collection.
collection.disable_search

collection.replace_field_sorting(name, equivalent_sort)

Replace sorting implementation for a field.
collection.replace_field_sorting('full_name', [
  { field: 'last_name', ascending: true },
  { field: 'first_name', ascending: true }
])

Form Field Types

Action forms support various field types.
collection.add_action('Example', {
  scope: 'Single',
  form: [
    {
      label: 'User Name',
      type: 'String',
      is_required: true,
      description: 'Enter the user name'
    },
    {
      label: 'Age',
      type: 'Number',
      default_value: 18
    },
    {
      label: 'Is Active',
      type: 'Boolean',
      default_value: true
    },
    {
      label: 'Birth Date',
      type: 'Date'
    },
    {
      label: 'Status',
      type: 'Enum',
      enum_values: ['pending', 'approved', 'rejected']
    }
  ],
  execute: ->(context, result_builder) {
    values = context.form_values
    # ... action logic
    result_builder.success
  }
})
Available Types:
  • 'String' - Text input
  • 'Number' - Numeric input
  • 'Boolean' - Checkbox
  • 'Date' - Date picker
  • 'Dateonly' - Date without time
  • 'Enum' - Single selection
  • 'EnumList' - Multiple selection
  • 'File' - File upload
  • 'Json' - JSON editor
  • 'Collection' - Record picker
Collection Field:
{
  label: 'Assign to User',
  type: 'Collection',
  collection_name: 'User'
}
Conditional Fields:
form: [
  {
    label: 'Notification Type',
    type: 'Enum',
    enum_values: ['email', 'sms', 'push']
  },
  {
    label: 'Email Address',
    type: 'String',
    if: ->(context) { context.form_values['notification_type'] == 'email' }
  }
]

Complete Example

# config/initializers/forest_admin_rails.rb
ForestAdminRails.configure do |config|
  config.auth_secret = ENV.fetch('FOREST_AUTH_SECRET')
  config.env_secret = ENV.fetch('FOREST_ENV_SECRET')
end
# app/lib/forest_admin_rails/create_agent.rb
module ForestAdminRails
  class CreateAgent
    def self.setup!
      database_configuration = Rails.configuration.database_configuration
      datasource = ForestAdminDatasourceActiveRecord::Datasource.new(database_configuration[Rails.env])

      @create_agent = ForestAdminAgent::Builder::AgentFactory.instance.add_datasource(datasource)
      customize
      @create_agent.build
    end

    def self.customize
      # Customize User collection
      @create_agent.customize_collection('User') do |collection|
        # Add computed field
        collection.add_field('full_name', {
          column_type: 'String',
          dependencies: ['first_name', 'last_name'],
          get_values: ->(records) {
            records.map { |r| "#{r['first_name']} #{r['last_name']}" }
          }
        })

        # Add segment
        collection.add_segment('Active users', {
          field: 'status',
          operator: 'Equal',
          value: 'active'
        })

        # Add action
        collection.add_action('Send promotional email', {
          scope: 'Bulk',
          form: [
            {
              label: 'Campaign',
              type: 'Enum',
              enum_values: ['summer', 'winter', 'black_friday']
            },
            {
              label: 'Discount',
              type: 'Number',
              is_required: true
            }
          ],
          execute: ->(context, result_builder) {
            campaign = context.form_values['campaign']
            discount = context.form_values['discount']
            users = context.get_records(['email'])

            users.each do |user|
              PromotionalMailer.campaign_email(
                user['email'],
                campaign,
                discount
              ).deliver_later
            end

            result_builder.success("Email sent to #{users.length} users")
          }
        })

        # Add hook
        collection.add_hook('Before', 'Create', ->(context) {
          context.data['status'] ||= 'active'
          context.data['created_by'] = context.caller.id
        })

        # Add chart
        collection.add_chart('user_count', ->(context, result_builder) {
          count = User.count
          result_builder.value(count)
        })
      end
    end
  end
end

Available Operators

Comparison:
  • 'Equal', 'NotEqual'
  • 'GreaterThan', 'LessThan'
  • 'In', 'NotIn'
  • 'Present', 'Blank'
String:
  • 'Contains', 'NotContains'
  • 'StartsWith', 'EndsWith'
  • 'Like', 'ILike' (case-insensitive)
Date:
  • 'Before', 'After'
  • 'Today', 'Yesterday'
  • 'PreviousWeek', 'PreviousMonth', 'PreviousQuarter', 'PreviousYear'
  • 'Past', 'Future'

Context Objects

Caller

User information available in all contexts:
context.caller.id          # User ID
context.caller.email       # User email
context.caller.first_name  # User first name
context.caller.last_name   # User last name
context.caller.team        # User team
context.caller.role        # User role
context.caller.timezone    # User timezone