Skip to main content
The Smart Actions API was redesigned to be more composable and explicit. The legacy API mixed declaration, form definition, and execution in a single object; the new agent splits these concerns and ties everything to a fluent collection-customization API.

API cheatsheet

Legacy agentNew agent
collection(name, { actions: [...] })agent.customizeCollection(name, c => c.addAction('Name', ...))
nameFirst argument of addAction
type: 'single' | 'bulk' | 'global'scope: 'Single' | 'Bulk' | 'Global'
fields: [...] (form definition)form: [...]
download: truegenerateFile: true
Express handler at custom routeexecute: (context, resultBuilder) => ...
req.body.data.attributes.valuescontext.formValues
req.body.data.attributes.idscontext.getRecordIds()
res.send({ success: '...' })resultBuilder.success('...')
res.send({ error: '...' })resultBuilder.error('...')
res.redirect(url)resultBuilder.redirectTo(url)

Before (Node.js, forest-express-sequelize)

// forest/users.js
const Liana = require('forest-express-sequelize');

Liana.collection('users', {
  actions: [{
    name: 'Send welcome email',
    type: 'single',
    fields: [
      { field: 'subject', type: 'String', isRequired: true },
      { field: 'message', type: 'String', widget: 'text area' },
    ],
  }],
});

// routes/users.js
const express = require('express');
const router = express.Router();

router.post('/actions/send-welcome-email', Liana.ensureAuthenticated, async (req, res) => {
  const { subject, message } = req.body.data.attributes.values;
  const userId = req.body.data.attributes.ids[0];
  const user = await models.users.findByPk(userId);

  await sendEmail(user.email, subject, message);

  res.send({ success: 'Email sent' });
});

module.exports = router;

After (Node.js, @forestadmin/agent)

agent.customizeCollection('users', users => {
  users.addAction('Send welcome email', {
    scope: 'Single',
    form: [
      { label: 'Subject', type: 'String', isRequired: true },
      { label: 'Message', type: 'String', widget: 'TextArea' },
    ],
    execute: async (context, resultBuilder) => {
      const user = await context.getRecord(['email']);
      const { Subject, Message } = context.formValues;

      await sendEmail(user.email, Subject, Message);

      return resultBuilder.success('Email sent');
    },
  });
});

Before (Ruby, forest-rails)

# app/services/forest_liana/actions/send_welcome_email.rb
class ForestLiana::Actions::SendWelcomeEmail < ForestLiana::SmartAction
  type 'single'

  fields([
    { field: 'subject', type: 'String', is_required: true },
    { field: 'message', type: 'String', widget: 'text area' },
  ])
end

# app/controllers/forest/users_controller.rb
class Forest::UsersController < ForestLiana::SmartActionsController
  def send_welcome_email
    user = User.find(params['data']['attributes']['ids'].first)
    values = params['data']['attributes']['values']

    UserMailer.welcome(user, values['subject'], values['message']).deliver_now

    render serializer: nil, json: { success: 'Email sent' }
  end
end

After (Ruby, forest_admin_rails)

Customizations live inside ForestAdminRails::CreateAgent.customize in app/lib/forest_admin_rails/create_agent.rb:
def self.customize
  @create_agent.customize_collection('User') do |collection|
    collection.add_action('Send welcome email', {
      scope: 'Single',
      form: [
        { label: 'Subject', type: 'String', is_required: true },
        { label: 'Message', type: 'String', widget: 'TextArea' },
      ],
      execute: ->(context, result_builder) {
        user = context.get_record(['email'])
        values = context.form_values

        UserMailer.welcome(user, values['Subject'], values['Message']).deliver_now

        result_builder.success('Email sent')
      },
    })
  end
end

Result types

The new agent supports the same result types as v1, plus a few new ones, all returned via resultBuilder:
ResultAPIUse case
Success messageresultBuilder.success(message)Confirmation toast
Error messageresultBuilder.error(message)Failure toast
RedirectresultBuilder.redirectTo(url)Open external URL
File downloadresultBuilder.file(buffer, filename, mimeType)Generate and download a file
HTML responseresultBuilder.webhookSuccess(message, html)Show custom HTML
See Action result types for details.

Form fields

Forms in the new agent are typed and support dynamic behavior more cleanly:
agent.customizeCollection('orders', orders => {
  orders.addAction('Refund', {
    scope: 'Single',
    form: [
      {
        label: 'Amount',
        type: 'Number',
        isRequired: true,
        defaultValue: async context => {
          const record = await context.getRecord(['total']);
          return record.total;
        },
      },
      {
        label: 'Reason',
        type: 'Enum',
        enumValues: ['Customer request', 'Defective product', 'Other'],
        isRequired: true,
      },
      {
        label: 'Note',
        type: 'String',
        widget: 'TextArea',
        if: context => context.formValues.Reason === 'Other',
      },
    ],
    execute: async (context, resultBuilder) => {
      // ...
    },
  });
});
See Action forms for the full API.

Approval workflows

If your legacy action used the approval system, the configuration moves from the action declaration to Project Settings → Roles. The action code itself doesn’t need changes. Forest’s UI handles the approval gating around your execute function.

Bulk and global actions

Legacy typeNew scope
'single''Single'
'bulk''Bulk'
'global''Global'
Bulk actions can use context.getRecordIds() to retrieve every selected record’s primary key, or context.getRecords(fields) to fetch the full records.

Common conversions

v1 used download: true and the route returned a file via res. v2 returns the file via resultBuilder.file(buffer, filename, mimeType) and sets generateFile: true in the action declaration.
v1 supported change hooks on form fields. v2 uses the if and defaultValue properties, which receive a context and the current form values. See Action forms.
v1 used Liana.ensureAuthenticated middleware and custom permission checks. v2 uses the standard role and team permission system configured in the UI. For dynamic checks (e.g. only the assigned rep can run an action), use action visibility conditions or check inside execute and return resultBuilder.error(...).
Direct port: execute calls your webhook URL the same way the v1 route handler did. The axios.post(...) (or Net::HTTP.post(...)) line is unchanged.

Migration checklist

1

List every Smart Action in your project

Pull from your forest/ (Node.js) or app/services/forest_liana/actions/ (Ruby) directory.
2

For each action, port the declaration

Replace Liana.collection(...).actions = [...] with agent.customizeCollection(...).addAction(...).
3

Port the form definition

Convert each field to a form entry. Update widget names (camelCase → PascalCase: 'text area''TextArea').
4

Port the execute logic

Move the route handler body into the execute function. Replace req.body.data.attributes.values with context.formValues.
5

Test in parallel

Run both agents and confirm each action behaves the same.

Next step

Migrate Smart Fields

Convert computed fields to the new addField API with explicit dependencies.