> ## Documentation Index
> Fetch the complete documentation index at: https://forest-docs-prd-616-workflow-webhook-trigger.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Migrating Smart Actions

> Convert legacy Smart Actions to the current agent's action API.

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 agent                           | New agent                                                        |
| -------------------------------------- | ---------------------------------------------------------------- |
| `collection(name, { actions: [...] })` | `agent.customizeCollection(name, c => c.addAction('Name', ...))` |
| `name`                                 | First argument of `addAction`                                    |
| `type: 'single' \| 'bulk' \| 'global'` | `scope: 'Single' \| 'Bulk' \| 'Global'`                          |
| `fields: [...]` (form definition)      | `form: [...]`                                                    |
| `download: true`                       | `generateFile: true`                                             |
| Express handler at custom route        | `execute: (context, resultBuilder) => ...`                       |
| `req.body.data.attributes.values`      | `context.formValues`                                             |
| `req.body.data.attributes.ids`         | `context.getRecordIds()`                                         |
| `res.send({ success: '...' })`         | `resultBuilder.success('...')`                                   |
| `res.send({ error: '...' })`           | `resultBuilder.error('...')`                                     |
| `res.redirect(url)`                    | `resultBuilder.redirectTo(url)`                                  |

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

```javascript theme={null}
// 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)

```javascript theme={null}
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)

```ruby theme={null}
# 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`:

```ruby theme={null}
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`:

| Result          | API                                              | Use case                     |
| --------------- | ------------------------------------------------ | ---------------------------- |
| Success message | `resultBuilder.success(message)`                 | Confirmation toast           |
| Error message   | `resultBuilder.error(message)`                   | Failure toast                |
| Redirect        | `resultBuilder.redirectTo(url)`                  | Open external URL            |
| File download   | `resultBuilder.file(buffer, filename, mimeType)` | Generate and download a file |
| HTML response   | `resultBuilder.webhookSuccess(message, html)`    | Show custom HTML             |

See [Action result types](/product/process/actions/custom-actions/result-types) for details.

## Form fields

Forms in the new agent are typed and support dynamic behavior more cleanly:

```javascript theme={null}
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](/product/process/actions/custom-actions/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 `type` | New `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

<AccordionGroup>
  <Accordion title="Action that returns a file download">
    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.
  </Accordion>

  <Accordion title="Action with dynamic form fields">
    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](/product/process/actions/custom-actions/forms).
  </Accordion>

  <Accordion title="Action with custom permission logic">
    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(...)`.
  </Accordion>

  <Accordion title="Action that triggers a webhook">
    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.
  </Accordion>
</AccordionGroup>

## Migration checklist

<Steps>
  <Step title="List every Smart Action in your project">
    Pull from your `forest/` (Node.js) or `app/services/forest_liana/actions/` (Ruby) directory.
  </Step>

  <Step title="For each action, port the declaration">
    Replace `Liana.collection(...).actions = [...]` with `agent.customizeCollection(...).addAction(...)`.
  </Step>

  <Step title="Port the form definition">
    Convert each `field` to a `form` entry. Update widget names (camelCase → PascalCase: `'text area'` → `'TextArea'`).
  </Step>

  <Step title="Port the execute logic">
    Move the route handler body into the `execute` function. Replace `req.body.data.attributes.values` with `context.formValues`.
  </Step>

  <Step title="Test in parallel">
    Run both agents and confirm each action behaves the same.
  </Step>
</Steps>

## Next step

<Card title="Migrate Smart Fields" icon="arrow-right" href="/guides/migration/from-v1/steps/smart-fields">
  Convert computed fields to the new `addField` API with explicit dependencies.
</Card>
