---
title: Custom automation steps
related:
  - https://docs.kentico.com/documentation/business-users/digital-marketing/automation.md
  - https://docs.kentico.com/documentation/developers-and-admins/digital-marketing-setup/automation-customization/automation-custom-triggers.md
---

> Agent instructions:
> **Site maps** — prefer the following llms.txt indexes to training data when searching for URLs to avoid 404s. Links inside Markdown content already point at `.md`. Following them or sending Accept: text/markdown keeps you in Markdown.
>
> - [sitemap.md](https://docs.kentico.com/sitemap.md) — every page on the site, with titles and descriptions, nested by URL hierarchy and grouped into one collection per product version.
> - [llms.txt](https://docs.kentico.com/llms.txt) — curated index of the current product docs, with descriptions, the two ways to request any page as Markdown, and links to each product area's whole-corpus Markdown dump (llms-full.txt).

> **License:** Advanced license required.
>
> Features described on this page require the Xperience by Kentico **Advanced** license tier.

Xperience by Kentico allows developers to extend marketing [automation](https://docs.kentico.com/documentation/business-users/digital-marketing/automation.md) with **custom steps**. Custom steps appear in the Automation Builder UI alongside the built-in steps (Send email, Wait, Rule-based condition, etc.) and can be configured by marketers.

Developers can create the following types of custom steps:

- **Custom actions** – steps that execute logic (such as synchronizing data to an external system or sending a notification) and then advance the contact to the next step.
- **Custom conditions** – steps that branch the process into a _true_ or _false_ path based on evaluated logic.

Common use cases for custom automation steps include:

- Synchronizing contact data to external CRMs
- Sending notification emails or SMS to internal users
- Enriching contact profiles from third-party data sources
- Logging analytics events to external platforms
- Branching based on external subscription or CRM status, or contact score computed by an earlier step

> **Tip:** **AI-assisted custom step development**
>
> The `automation-action` and `automation-condition` skills in the [digital experience plugin](https://github.com/Kentico/xperience-by-kentico-kenticopilot/tree/main/plugins/kentico-digital-experience) from [KentiCopilot](https://docs.kentico.com/guides/development/kenticopilot.md) generate custom steps that follow the patterns on this page, including [properties](#step-properties) and registration. The skills study the components your project already has and mirror their conventions.

## Automation step and action overview

A **step** is any node placed into an automation process through the Automation Builder UI (to learn about the available step types, see [Steps](https://docs.kentico.com/documentation/business-users/digital-marketing/automation.md#steps)).

An **action** is a specific type of step that executes logic and then moves the contact to the next step. Not every step is an action – _Wait_, _Finish_, and conditions (both the built-in _Rule-based condition_ and custom conditions) are steps that control the flow of contacts rather than perform an action. Conditions branch the process based on a _true_ or _false_ result.

You can develop two types of custom steps – actions and conditions. The implementation differs in what the steps do and return, but both step types receive the same [Runtime context](#runtime-context), share the same [Step properties](#step-properties), and should follow many of the same [Best practices](#best-practices).

To learn more:

- [Create custom actions](#create-custom-actions)
- [Create custom conditions](#create-custom-conditions)

## Create custom actions

Custom actions are classes that implement specific logic executed when a contact reaches the corresponding step in an automation process. Two patterns are available:

- [Actions without configurable properties](#create-custom-actions-without-properties) – inherit from `AutomationAction`. Use when the action's behavior is fully defined in code.
- [Actions with configurable properties](#create-custom-actions-with-properties) – inherit from `AutomationAction<TProperties>`. Use when marketers need to configure the action's behavior through the Automation Builder UI.

> **Key:** **Key points**
>
> - Inherit from `AutomationAction` (no properties) or `AutomationAction<TProperties>` (with configurable properties).
> - Implement the action's logic by overriding the `Execute` method.
> - Use the `AutomationProcessContext` parameter of the `Execute` method to access contextual [runtime data](#runtime-context) (such as the processed contact), and [share data between steps](#share-data-between-automation-steps) within the process using data containers implementing `IAutomationProcessData`.
> - Register actions via the `RegisterAutomationAction<TAction>` assembly attribute.

![Overview of custom automation action components](https://docs.kentico.com/docsassets/documentation/automation-custom-steps/automation_actions_overview.drawio.svg "Overview of custom automation action components")

### Create custom actions without properties

To create an action that doesn't require marketer-configurable settings:

1. Create a class inheriting from `AutomationAction`.
2. Override the `Execute` method and implement the action's logic.
   - You can access information from the [runtime context](#runtime-context) of the process, including [data stored by previous steps](#share-data-between-automation-steps).
3. [Register the action](#register-custom-actions) via the `RegisterAutomationAction<TAction>` assembly attribute.

```csharp title="Example - Audit log action"
using Microsoft.Extensions.Logging;
using System.Threading;
using System.Threading.Tasks;

using CMS.Automation;
using CMS.ContactManagement;
using Kentico.Xperience.Admin.Base;

using Acme.Automation;

[assembly: RegisterAutomationAction<AuditLogAction>(
    identifier: AuditLogAction.IDENTIFIER,
    displayName: "Log to audit system",
    Description = "Logs an audit event to an external logging service.",
    IconName = Icons.BoxCogwheel)]

namespace Acme.Automation;

public class AuditLogAction : AutomationAction
{
    public const string IDENTIFIER = "Acme.AuditLog";

    private readonly ILogger<AuditLogAction> logger;

    public AuditLogAction(ILogger<AuditLogAction> logger)
    {
        this.logger = logger;
    }

    public override async Task Execute(AutomationProcessContext context, CancellationToken cancellationToken)
    {
        // Gets the processed contact
        ContactInfo contact = await context.GetProcessedObject(cancellationToken);

        // Gets the display name of the current automation process
        var processName = context.Process.DisplayName;

        // Logs an audit event
        // Assumes the application contains an external logging provider that receives events from "Acme" namespaces
        logger.LogInformation("Contact '{ContactFirstName} {ContactLastName}' reached the audit step in process '{ProcessName}'.",
    contact.ContactFirstName, contact.ContactLastName, processName);
    }
}
```

Marketers can now add the action as a step when designing [automation processes](https://docs.kentico.com/documentation/business-users/digital-marketing/automation.md). When a contact enters the step, the action's `Execute` method runs and the contact moves to the next step.

### Create custom actions with properties

To create an action with settings configurable in the [Automation Builder UI](https://docs.kentico.com/documentation/business-users/digital-marketing/automation.md):

1. Create a properties class implementing the `IAutomationActionProperties` interface.
2. Define each property by adding a C# property in the class.
3. Assign [editing components](https://docs.kentico.com/documentation/developers-and-admins/customization/extend-the-administration-interface/ui-form-components/editing-components.md) to define the visual interface for properties in the action's configuration dialog.
   - See [Step properties](#step-properties) for more information.
4. Create an action class inheriting from `AutomationAction<TProperties>`, where `TProperties` is your properties class.
5. Override the `Execute` method and implement the action's logic.
   - The method receives the properties instance populated with values from the action's configuration.
   - You can access information from the [runtime context](#runtime-context) of the process, including [data stored by previous steps](#share-data-between-automation-steps).
6. [Register the action](#register-custom-actions) via the `RegisterAutomationAction` assembly attribute.

```csharp title="Example - Email notification action"
using System.Threading;
using System.Threading.Tasks;

using CMS.Automation;
using CMS.ContactManagement;
using CMS.EmailEngine;

using Kentico.Xperience.Admin.Base;
using Kentico.Xperience.Admin.Base.FormAnnotations;

using Acme.Automation;

[assembly: RegisterAutomationAction<EmailNotificationAction>(
    identifier: EmailNotificationAction.IDENTIFIER,
    displayName: "Send email notification",
    Description = "Sends an email with a specified subject and body to a designated recipient.",
    IconName = Icons.MessageMicro)]

namespace Acme.Automation;

public class EmailNotificationActionProperties : IAutomationActionProperties
{
    // Text input for entering the recipient email address
    [TextInputComponent(Label = "Recipient email", Order = 10)]
    // Validation rule to enforce valid email address format
    [EmailValidationRule]
    // The value for this property is required
    [RequiredValidationRule]
    public string RecipientEmail { get; set; }

    // Text input for entering the notification email subject
    // Default value set to "Automation notification"
    [TextInputComponent(Label = "Subject", Order = 20)]
    // The value for this property is required
    [RequiredValidationRule]
    public string Subject { get; set; } = "Automation notification";

    // Rich text editor for entering the notification email body
    [RichTextEditorComponent(Label = "Message body", Order = 30)]
    public string Body { get; set; }
}

public class EmailNotificationAction : AutomationAction<EmailNotificationActionProperties>
{
    public const string IDENTIFIER = "Acme.SendEmailNotification";

    private readonly IEmailService emailService;

    public EmailNotificationAction(IEmailService emailService)
    {
        this.emailService = emailService;
    }

    public override async Task Execute(
        EmailNotificationActionProperties properties,
        AutomationProcessContext context,
        CancellationToken cancellationToken)
    {
        if (string.IsNullOrEmpty(properties.RecipientEmail) || string.IsNullOrEmpty(properties.Subject))
        {
            return;
        }

        // Gets the processed contact
        ContactInfo contact = await context.GetProcessedObject(cancellationToken);

        var body = $"{properties.Body}" +
                   $"<hr>" +
                   $"<h3>Contact details</h3>" +
                   $"Name: {contact.ContactFirstName} {contact.ContactLastName}<br>" +
                   $"Email: {contact.ContactEmail}";

        // Sends the email message
        var message = new EmailMessage
        {
            From = "automation@localhost.local",
            Recipients = properties.RecipientEmail,
            Subject = properties.Subject,
            Body = body
        };

        await emailService.SendEmail(message);
    }
}
```

Marketers can now add the action as a step when designing [automation processes](https://docs.kentico.com/documentation/business-users/digital-marketing/automation.md) and configure the available properties.

![Configuration of a custom action's properties in the Automation Builder UI](https://docs.kentico.com/docsassets/documentation/automation-custom-steps/custom_action_properties.png "Configuration of a custom action's properties in the Automation Builder UI")

When a contact enters the step, the action's `Execute` method runs with the given property values and the contact moves to the next step.

### Register custom actions

Register custom actions using the `RegisterAutomationAction<TAction>` assembly attribute. We recommend placing the attribute at the top of the action's source file.

Specify the following parameters:

- `identifier` – a unique, stable string identifier for the action. We recommend using a unique prefix in your identifiers to prevent conflicts. For example, include your company's name as a prefix (`Acme.SyncContactAction`). Never change this value once the action is deployed and used in automation processes.
  > **Note:** For easy access and reuse, store the identifier value in the action class within an `IDENTIFIER` constant.

- `displayName` – the name displayed in the Automation Builder step selector.

You can also set the following optional properties:

- `IconName` – the icon displayed for the action tile in the builder. Use icons from the set of icons starting with the _xp-_ prefix, referenced by the constants in the `Kentico.Xperience.Admin.Base.Icons` class.
- `Description` – hover text displayed as a tooltip in the Automation Builder step selector.

```csharp title="Action registration example"
using CMS.Automation;
using Kentico.Xperience.Admin.Base;

[assembly: RegisterAutomationAction<SyncContactToCrmAction>(
    identifier: SyncContactToCrmAction.IDENTIFIER,
    displayName: "Sync contact to CRM",
    IconName = Icons.ArrowRightTopSquare,
    Description = "Pushes the current contact's data to the external CRM.")]
```

Once registered, the action appears under the **Steps** category in the step selection dialog when adding steps in the Automation Builder.

> **Warning:** Deleting or unregistering an action that is used in an existing automation process breaks the process. If a process contains an "unknown" action, the Automation Builder UI will not be available to fix or remove the given action step. The process cannot be recovered until all used actions are restored and registered.

## Create custom conditions

Custom conditions branch an automation process into a _true_ or _false_ path based on logic you evaluate in code. When a contact reaches a custom condition step, the system calls the condition's `Evaluate` method and moves the contact along the path matching the returned value.

Two condition patterns are available:

- [Conditions without configurable properties](#create-custom-conditions-without-properties) – inherit from `AutomationCondition`. Use when the condition's logic is fully defined in code.
- [Conditions with configurable properties](#create-custom-conditions-with-properties) – inherit from `AutomationCondition<TProperties>`. Use when marketers need to configure the condition's behavior through the Automation Builder UI.

> **Key:** **Key points**
>
> - Inherit from `AutomationCondition` (no properties) or `AutomationCondition<TProperties>` (with configurable properties).
> - Implement the branching logic by overriding the `Evaluate` method and returning `true` or `false`.
> - Use the `AutomationProcessContext` parameter of the `Evaluate` method to access contextual [runtime data](#runtime-context) (such as the processed contact) or [data stored by previous steps](#share-data-between-automation-steps).
> - Keep conditions read-only – evaluate and return a result without modifying data or causing side effects.
> - Register conditions via the `RegisterAutomationCondition<TCondition>` assembly attribute.

![Overview of custom automation condition components](https://docs.kentico.com/docsassets/documentation/automation-custom-steps/automation_conditions_overview.drawio.svg "Overview of custom automation condition components")

### Create custom conditions without properties

To create a condition that doesn't require marketer-configurable settings:

1. Create a class inheriting from `AutomationCondition`.
2. Override the `Evaluate` method and implement the condition's logic. Return `true` to follow the true branch or `false` to follow the false branch.
   - You can access information from the [runtime context](#runtime-context) of the process, including [data stored by previous steps](#share-data-between-automation-steps).
3. [Register the condition](#register-custom-conditions) via the `RegisterAutomationCondition<TCondition>` assembly attribute.

```csharp title="Example - Active subscription condition"
using System.Threading;
using System.Threading.Tasks;

using CMS.Automation;
using CMS.ContactManagement;

using Kentico.Xperience.Admin.Base;

using Acme.Automation;

[assembly: RegisterAutomationCondition<HasActiveSubscriptionCondition>(
    identifier: HasActiveSubscriptionCondition.IDENTIFIER,
    displayName: "Has active subscription",
    Description = "Branches based on whether the contact has an active subscription in the billing system.",
    IconName = Icons.CheckCircle)]

namespace Acme.Automation;

public class HasActiveSubscriptionCondition : AutomationCondition
{
    public const string IDENTIFIER = "Acme.HasActiveSubscriptionCondition";

    // Represents a custom service that checks subscription status in an external billing system
    private readonly ISubscriptionService subscriptionService;

    public HasActiveSubscriptionCondition(ISubscriptionService subscriptionService)
    {
        this.subscriptionService = subscriptionService;
    }

    public override async Task<bool> Evaluate(AutomationProcessContext context, CancellationToken cancellationToken)
    {
        // Gets the processed contact
        ContactInfo contact = await context.GetProcessedObject(cancellationToken);

        // Returns true/false to send the contact to the corresponding process branch
        return await subscriptionService.HasActiveSubscription(contact.ContactEmail, cancellationToken);
    }
}
```

Marketers can now add the condition as a step when designing [automation processes](https://docs.kentico.com/documentation/business-users/digital-marketing/automation.md) and connect its true and false branches to other steps.

### Create custom conditions with properties

To create a condition with settings configurable in the [Automation Builder UI](https://docs.kentico.com/documentation/business-users/digital-marketing/automation.md):

1. Create a properties class implementing the `IAutomationConditionProperties` interface.
2. Define each property by adding a C# property in the class.
3. Assign [editing components](https://docs.kentico.com/documentation/developers-and-admins/customization/extend-the-administration-interface/ui-form-components/editing-components.md) to define the visual interface for properties in the condition's configuration dialog.
   - See [Step properties](#step-properties) for more information.
4. Create a condition class inheriting from `AutomationCondition<TProperties>`, where `TProperties` is your properties class.
5. Override the `Evaluate` method and implement the condition's logic.
   - The method receives the properties instance populated with values from the condition's configuration.
   - You can access information from the [runtime context](#runtime-context) of the process, including [data stored by previous steps](#share-data-between-automation-steps).
6. [Register the condition](#register-custom-conditions) via the `RegisterAutomationCondition` assembly attribute.

```csharp title="Example - Score threshold condition"
using Microsoft.Extensions.Logging;
using System.Threading;
using System.Threading.Tasks;

using CMS.Automation;

using Kentico.Xperience.Admin.Base;
using Kentico.Xperience.Admin.Base.FormAnnotations;

using Acme.Automation;

[assembly: RegisterAutomationCondition<ScoreThresholdCondition>(
    identifier: ScoreThresholdCondition.IDENTIFIER,
    displayName: "Contact score reaches threshold",
    Description = "Branches based on whether the contact's score reaches a configured threshold.",
    IconName = Icons.StarFull)]

namespace Acme.Automation;

public class ScoreThresholdConditionProperties : IAutomationConditionProperties
{
    // Number input for entering the score threshold
    [NumberInputComponent(Label = "Score threshold", Order = 10)]
    // The threshold cannot be negative
    [MinimumIntegerValueValidationRule(0)]
    public int Threshold { get; set; } = 50;
}

public class ScoreThresholdCondition : AutomationCondition<ScoreThresholdConditionProperties>
{
    public const string IDENTIFIER = "Acme.ScoreThresholdCondition";

    private readonly ILogger<ScoreThresholdCondition> logger;

    public ScoreThresholdCondition(ILogger<ScoreThresholdCondition> logger)
    {
        this.logger = logger;
    }

    public override async Task<bool> Evaluate(
        ScoreThresholdConditionProperties properties,
        AutomationProcessContext context,
        CancellationToken cancellationToken)
    {
        // Gets the contact score stored earlier in the process by a preceding scoring action.
        // 'ContactScoreData' is a shared process data class that implements IAutomationProcessData
        // (see the 'Example - Two-step data sharing' section).
        ContactScoreData scoreData = await context.GetProcessData<ContactScoreData>(cancellationToken);

        // Follows the false branch when no score has been stored yet, for example when the
        // process is missing the preceding step that calculates and stores the score.
        if (scoreData is null)
        {
            logger.LogWarning("No score data found in the process context.");
            return false;
        }

        // Follows the true branch when the stored score reaches the configured threshold
        return scoreData.Score >= properties.Threshold;
    }
}
```

Marketers can now add the condition as a step when designing [automation processes](https://docs.kentico.com/documentation/business-users/digital-marketing/automation.md) and configure the available properties. When a contact enters the step, the condition's `Evaluate` method runs with the given property values, and the contact follows the true or false branch based on the returned result.

### Register custom conditions

Register custom conditions using the `RegisterAutomationCondition<TCondition>` assembly attribute. We recommend placing the attribute at the top of the condition's source file.

Specify the following parameters:

- `identifier` – a unique, stable string identifier for the condition. We recommend using a unique prefix in your identifiers to prevent conflicts. For example, include your company's name as a prefix (`Acme.HasActiveSubscriptionCondition`). Never change this value once the condition is deployed and used in automation processes.
  > **Note:** For easy access and reuse, store the identifier value in the condition class within an `IDENTIFIER` constant.

- `displayName` – the name displayed in the Automation Builder step selector.

You can also set the following optional properties:

- `IconName` – the icon displayed for the condition tile in the builder. Use icons from the set of icons starting with the _xp-_ prefix, referenced by the constants in the `Kentico.Xperience.Admin.Base.Icons` class.
- `Description` – hover text displayed as a tooltip in the Automation Builder step selector.

```csharp title="Condition registration example"
using CMS.Automation;
using Kentico.Xperience.Admin.Base;

[assembly: RegisterAutomationCondition<HasActiveSubscriptionCondition>(
    identifier: HasActiveSubscriptionCondition.IDENTIFIER,
    displayName: "Has active subscription",
    IconName = Icons.CheckCircle,
    Description = "Branches based on whether the contact has an active subscription.")]
```

Once registered, the condition appears under the **Conditions** category in the step selection dialog when adding steps in the Automation Builder.

> **Warning:** Deleting or unregistering a condition that is used in an existing automation process breaks the process. If a process contains an "unknown" condition, the Automation Builder UI will not be available to fix or remove the given condition step. The process cannot be recovered until all used conditions are restored and registered.

## Step properties

Properties define what marketers can configure for each instance of an automation process step, condition, or trigger. To define properties, create a class implementing the appropriate interface (`IAutomationActionProperties`, `IAutomationConditionProperties`, or `IAutomationTriggerProperties`) and decorate its properties with [editing components](https://docs.kentico.com/documentation/developers-and-admins/customization/extend-the-administration-interface/ui-form-components/editing-components.md) (UI form component attributes).

To learn about the available UI form components, see [Reference - Admin UI form components](https://docs.kentico.com/documentation/developers-and-admins/customization/extend-the-administration-interface/ui-form-components/reference-admin-ui-form-components.md). The following are common examples:

- `TextInputComponent` – single-line text input
- `DropDownComponent` – drop-down selection
  - supports both static options and [dynamic options](#dynamic-drop-down-options) via `IDropDownOptionsProvider`
- `CheckBoxComponent` – boolean toggle
- `DateTimeInputComponent` – date and time picker
- `RichTextEditorComponent` – rich text editor

Only public properties with both a getter and a setter participate in property mapping.

### Set default property values

You can set default values for step properties. These values are set when marketers add a new instance of a step or trigger to a process.

```csharp title="Step property with a default value"
using Kentico.Xperience.Admin.Base.FormAnnotations;

// ...

// Default value set to "Automation notification"
[TextInputComponent(Label = "Subject", Order = 20)]
public string Subject { get; set; } = "Automation notification";
```

### Customize the step name input

By default, all automation step configuration dialogs include a **Step name** input. If your properties class defines a property named `StepDisplayName`, you can configure its form component annotation (label, validation, etc.) to replace the default _Step name_ input.

```csharp title="Customizing the step name input"
using Kentico.Xperience.Admin.Base.FormAnnotations;

// ...

// Sets a custom label for the "Step name" property
[TextInputComponent(Label = "Notification step title")]
public string StepDisplayName { get; set; }
```

> **Note:** You cannot adjust the order of the step name property. The name always appears first in the configuration dialog.

### Dynamic drop-down options

Use `IDropDownOptionsProvider` to populate a `DropDownComponent` with options resolved at runtime:

```csharp title="IDropDownOptionsProvider with options for selecting a CRM"
using System.Collections.Generic;
using System.Threading.Tasks;

using Kentico.Xperience.Admin.Base.FormAnnotations;

public class CrmSystemOptionsProvider : IDropDownOptionsProvider
{
    public Task<IEnumerable<DropDownOptionItem>> GetOptionItems() =>
        Task.FromResult<IEnumerable<DropDownOptionItem>>(
        [
            new DropDownOptionItem { Value = "crm1", Text = "CRM 1" },
            new DropDownOptionItem { Value = "crm2", Text = "CRM 2" },
            new DropDownOptionItem { 
                Value = "crm3",
                Text = "CRM 3",
                Tooltip = "Requires CRM connector configuration. Consult your administrator before use."
            }
        ]);
}
```

Reference the provider using the `DataProviderType` parameter on the `DropDownComponent` attribute:

```csharp title="Property using DropDownComponent with a custom data provider"
using Kentico.Xperience.Admin.Base.FormAnnotations;

// ...

[DropDownComponent(
    Label = "CRM system",
    DataProviderType = typeof(CrmSystemOptionsProvider),
    Order = 10)]
public string CrmSystem { get; set; }
```

### Property validation, visibility and UI organization

The following options allow you to further improve the user experience of automation action, condition, or trigger configuration dialogs:

- **Form categories** – organize properties in the configuration dialog using `[FormCategory]` attributes. Categories group properties together under labeled sections and support `Collapsible` and `IsCollapsed` options. See [Group components into categories](https://docs.kentico.com/documentation/developers-and-admins/customization/extend-the-administration-interface/ui-form-components/editing-components.md#group-components-into-categories) for details.
- **Validation rules** – add attributes that enforce restrictions on property values (`RequiredValidationRule`, `MaxLengthValidationRule`, etc.). The system provides a set of [default validation rules](https://docs.kentico.com/documentation/developers-and-admins/customization/extend-the-administration-interface/ui-form-components/ui-form-component-validation-rules.md#default-validation-rules), and developers can implement custom ones. See [UI form component validation rules](https://docs.kentico.com/documentation/developers-and-admins/customization/extend-the-administration-interface/ui-form-components/ui-form-component-validation-rules.md) for details.
- **Conditional visibility** – add attributes that dynamically hide or display properties in the configuration dialog based on the value of another property (`VisibleIfEmpty`, `VisibleIfTrue`, etc.). The system provides a set of [default visibility conditions](https://docs.kentico.com/documentation/developers-and-admins/customization/extend-the-administration-interface/ui-form-components/ui-form-component-visibility-conditions.md#default-visibility-conditions), and developers can implement custom ones. See [UI form component visibility conditions](https://docs.kentico.com/documentation/developers-and-admins/customization/extend-the-administration-interface/ui-form-components/ui-form-component-visibility-conditions.md) for details.

```csharp title="Comprehensive properties example"
using CMS.Automation;

using Kentico.Xperience.Admin.Base.FormAnnotations;

[FormCategory(Label = "General", Order = 10)]
[FormCategory(Label = "Advanced", Order = 110, Collapsible = true, IsCollapsed = true)]
public class CrmSyncActionProperties : IAutomationActionProperties
{
    // General category

    // Drop-down selector with predefined options
    [DropDownComponent(Label = "CRM System",
        Options = "crm1;CRM 1\ncrm2;CRM 2\ncrm3;CRM 3",
        Order = 11)]
    [RequiredValidationRule]
    public string CrmSystem { get; set; } = "crm1";

    // Checkbox property enabled by default
    [CheckBoxComponent(Label = "Update existing records", Order = 21)]
    public bool UpdateExisting { get; set; } = true;

    // Advanced category

    // Number input with a default value that allows values between 1 and 10
    [NumberInputComponent(Label = "Retry attempts", Order = 111)]
    [MinimumIntegerValueValidationRule(1)]
    [MaximumIntegerValueValidationRule(10)]
    public int RetryAttempts { get; set; } = 3;

    // Note rich text editor visible if 'Update existing records' is enabled
    [RichTextEditorComponent(Label = "Notes", Order = 121)]
    [VisibleIfTrue(nameof(UpdateExisting))]
    public string Notes { get; set; }
}
```

> **Tip:** **Property guidelines**
>
> Follow our [Property guidelines](#property-guidelines) to keep your properties organized and easy to work with in the configuration dialog.

## Runtime context

The `AutomationProcessContext` class is provided to the `Execute` method of every action and the `Evaluate` method of every condition, and gives access to runtime data and operations. The process context allows you to:

- [Access the processed contact](#access-the-processed-contact)
- [Access the process name](#access-the-process-name)
- [Access trigger data](#access-trigger-data)
- [Share data between automation steps](#share-data-between-automation-steps)

> **Note:** **Conditions should be read-only**
>
> Conditions have full access to the runtime context, but they should only _read_ data to evaluate their result. Avoid modifying contacts, storing [process data](#share-data-between-automation-steps), or causing other side effects in a condition – a condition may be re-evaluated as contacts move through the process. Produce data in a preceding **action** and read it in the condition. See [Condition guidelines](#condition-guidelines).

### Access the processed contact

To retrieve the contact that is being processed by the action, call the `GetProcessedObject` extension method (from the `CMS.ContactManagement` namespace) on `AutomationProcessContext`. The method returns a `ContactInfo` object representing the contact. You can access the object's properties to get the contact's data or use the [contact API](https://docs.kentico.com/api/digital-marketing/contacts.md) to modify it.

```csharp title="Getting the processed contact"
using CMS.Automation;
using CMS.ContactManagement;

// ...

public override async Task Execute(AutomationProcessContext context, CancellationToken cancellationToken)
{
    // Returns the processed ContactInfo
    ContactInfo contact = await context.GetProcessedObject(cancellationToken);

    // Accesses contact properties
    string contactName = contact.ContactDescriptiveName;
    int contactId = contact.ContactID;

    // ...
}
```

### Access the process name

To retrieve the display name of the current automation process, use the `Process.DisplayName` property of `AutomationProcessContext`.

```csharp title="Getting the current process name"
using CMS.Automation;

// ...

public override async Task Execute(AutomationProcessContext context, CancellationToken cancellationToken)
{
    // Gets the display name of the current automation process
    string processName = context.Process.DisplayName;

    // ...
}
```

### Access trigger data

Automation processes are started by [triggers](https://docs.kentico.com/documentation/business-users/digital-marketing/automation.md#triggers). The system captures data about the trigger for each instance of a process, and this data can be retrieved and used within the execution of custom actions. For example, when a process is triggered by a form submission, you can access the form and specific form data that was submitted.

Retrieve trigger data using the `GetTriggerData<T>` method, where `T` is one of the available trigger data types.

- For [custom triggers](https://docs.kentico.com/documentation/developers-and-admins/digital-marketing-setup/automation-customization/automation-custom-triggers.md) with data, see [Access custom trigger data](#access-custom-trigger-data).
- For the built-in triggers, the system provides the following data types:

| Trigger data type           | Properties                                                                                                                                                                                                                                                                                                                                                                                       |
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `FormSubmissionTriggerData` | `BizFormId` – identifier of the form that was submitted<br>`BizFormItemId` – identifier of the submitted form record (form item)                                                                                                                                                                                                                                                                 |
| `RegistrationTriggerData`   | `MemberId` – identifier of the member that registered                                                                                                                                                                                                                                                                                                                                            |
| `CustomActivityTriggerData` | `ActivityItemId` – identifier of the object the activity is associated with<br>`ActivityItemDetailId` – identifier of the detail object the activity is associated with<br>`ActivityValue` – optional value carried by the activity<br>`ActivityChannelId` – identifier of the channel where the activity was recorded<br>`ActivityType` – code name of the activity type that fired the trigger |

`GetTriggerData<T>` returns `null` if the process was not started by the matching trigger. Check the result for `null` before using the data, or call the following extension methods on `AutomationProcessContext` to create conditions in your code:

- `TriggeredByCustomActivity`
- `TriggeredByFormSubmission`
- `TriggeredByRegistration`

The methods return `Task<bool>` indicating whether the process was started by the corresponding built-in trigger.

See [Example - Getting form submission trigger data](#example---getting-form-submission-trigger-data) for an example of trigger data handling.

#### Access custom trigger data

Processes started by a [custom trigger](https://docs.kentico.com/documentation/developers-and-admins/digital-marketing-setup/automation-customization/automation-custom-triggers.md) can also carry a typed data payload. Retrieve it with `GetTriggerData<T>`, where `T` is the custom trigger's data type (a class implementing `IAutomationTriggerData`). The method returns `null` when the process was started by a different trigger or when the custom trigger carried no data.

The `TriggeredBy...` extension methods listed above cover only the built-in triggers. To detect a custom trigger, use the `GetTriggerIdentifier` method of `AutomationProcessContext` and compare the returned value against the `identifier` under which the given trigger is [registered](https://docs.kentico.com/documentation/developers-and-admins/digital-marketing-setup/automation-customization/automation-custom-triggers.md#register-custom-triggers) (not the identifier of the trigger's data class).

```csharp title="Reading custom trigger data"
using CMS.Automation;

using Acme.Automation;

// ...

public override async Task Execute(AutomationProcessContext context, CancellationToken cancellationToken)
{
    // Gets the identifier of the trigger that started the process
    string triggerIdentifier = await context.GetTriggerIdentifier(cancellationToken);

    // Only proceeds when the process was started by a specific custom trigger
    if (!string.Equals(triggerIdentifier, PurchaseCompletedTrigger.IDENTIFIER))
    {
        return;
    }

    // Gets the custom trigger's typed data
    PurchaseData purchaseData = await context.GetTriggerData<PurchaseTriggerData>(cancellationToken);
    if (purchaseData is null)
    {
        return;
    }

    // Custom logic that uses the trigger data
    var total = purchaseData.TotalAmount;

    // ...
}
```

#### Example - Getting form submission trigger data

The following example shows an action that reacts to a [form](https://docs.kentico.com/documentation/business-users/digital-marketing/forms.md) submission trigger. It reads the `FormSubmissionTriggerData`, loads the corresponding form record (`BizFormItem`), and [caches](https://docs.kentico.com/documentation/developers-and-admins/development/caching/data-caching.md) the loaded record under a unique cache key based on the form item ID.

```csharp title="Loading and caching a submitted form record"
using System.Threading;
using System.Threading.Tasks;

using CMS.Automation;
using CMS.DataEngine;
using CMS.Helpers;
using CMS.OnlineForms;
using Kentico.Xperience.Admin.Base;

using Acme.Automation;

[assembly: RegisterAutomationAction<ProcessFormSubmissionAction>(
    identifier: ProcessFormSubmissionAction.IDENTIFIER,
    displayName: "Process form submission",
    Description = "Processes the data submitted by the form submission trigger.",
    IconName = Icons.Form)]

namespace Acme.Automation;

public class ProcessFormSubmissionAction : AutomationAction
{
    public const string IDENTIFIER = "Acme.ProcessFormSubmission";

    private readonly IInfoProvider<BizFormInfo> bizFormInfoProvider;
    private readonly IProgressiveCache progressiveCache;

    public ProcessFormSubmissionAction(
        IInfoProvider<BizFormInfo> bizFormInfoProvider,
        IProgressiveCache progressiveCache)
    {
        this.bizFormInfoProvider = bizFormInfoProvider;
        this.progressiveCache = progressiveCache;
    }

    public override async Task Execute(AutomationProcessContext context, CancellationToken cancellationToken)
    {
        // Only proceeds when the process was triggered by a form submission
        if (!await context.TriggeredByFormSubmission(cancellationToken))
        {
            return;
        }

        // Gets the trigger data captured when the form was submitted
        FormSubmissionTriggerData formData = await context.GetTriggerData<FormSubmissionTriggerData>(cancellationToken);
        if (formData is null)
        {
            return;
        }

        // Loads the submitted form record (cached)
        BizFormItem formItem = await GetFormItem(formData, cancellationToken);
        if (formItem is null)
        {
            return;
        }

        // Processes the submitted form record, e.g. reads its field values
        string email = formItem.GetStringValue("UserEmail", string.Empty);

        // ... custom logic
    }

    // Loads the submitted form record and caches it under a cache key unique to the submitted form and record
    private async Task<BizFormItem> GetFormItem(FormSubmissionTriggerData formData, CancellationToken cancellationToken)
    {
        return await progressiveCache.LoadAsync(async (cacheSettings, cancellationToken) =>
        {
            // Gets the form definition and resolves its class name
            BizFormInfo formInfo = await bizFormInfoProvider.GetAsync(formData.BizFormId, cancellationToken);
            if (formInfo is null)
            {
                // Does not cache when the form definition no longer exists
                cacheSettings.Cached = false;
                return null;
            }

            string formClassName = DataClassInfoProvider.GetDataClassInfo(formInfo.FormClassID).ClassName;

            // Loads the submitted form record by its ID
            BizFormItem formItem = BizFormItemProvider.GetItem(formData.BizFormItemId, formClassName);

            // Does not cache a missing record
            cacheSettings.Cached = formItem != null;

            return formItem;
        },
        // Unique cache key based on the submitted form and record IDs
        new CacheSettings(
            cacheMinutes: 10,
            IDENTIFIER, nameof(GetFormItem), formData.BizFormId, formData.BizFormItemId),
        cancellationToken);
    }
}
```

> **Tip:** **Form data cache dependencies**
>
> The system does not provide a default cache dependency key for automatic clearing of cached form data. As a workaround, you can use a custom cache key and touch it from a custom event handler. See [Cache dependency keys - Form data records](https://docs.kentico.com/documentation/developers-and-admins/development/caching/cache-dependencies-reference.md#form-data-records).

### Share data between automation steps

Custom actions within the same automation process can share typed data using `AutomationProcessContext` and its `SetProcessData<T>(T)` and `GetProcessData<T>()` methods. This allows one action to produce data that a later action consumes, even if _Wait_ steps or other actions are between them.

**Key characteristics of shared process data**:

- Each data type used by actions implements the `IAutomationProcessData` interface, and is identified by the `Identifier` string property.

- Multiple process data types can coexist within the same process (assuming they all have unique identifiers).

- If a process contains multiple instances of the same action that stores data or there are multiple actions storing data of the same type, this data is **shared** throughout the process and potentially can be overwritten.
  > **Tip:** If you need to store isolated data for each action instance, include a collection property in your process data type, and store values under unique keys (e.g., based on a value from the action's instance-specific [properties](#step-properties)).

- Process data is persisted in the database as JSON, so it survives _Wait_ steps, application restarts, and redeployments.

- All `DateTime` values are automatically serialized and deserialized as UTC.

> **Note:** **Handle missing process data**
>
> `GetProcessData<T>` returns `null` whenever data of type `T` has not been stored yet or cannot be deserialized (e.g., after a process data class update). Treat a `null` result as an expected outcome rather than an error. Never assume that the process contains a specific sequence of steps or that steps will always run successfully.
>
> When an action depends on data from a previous step, check for `null` and [log](https://docs.kentico.com/documentation/developers-and-admins/development/logging.md) a warning or error before returning. This allows you to detect automation processes that are built in the wrong order or are missing a required step for your intended workflow.

#### Example - Two-step data sharing

The following example demonstrates a common pattern – one action writes data and a subsequent action reads it.

**Process data class**

Implement the `IAutomationProcessData` interface. The `Identifier` property must be a unique, stable string. We recommend using a unique prefix in your identifiers to prevent conflicts. For example, include your company’s name as a prefix.

```csharp title="ContactScoreData process data class"
using System;

using CMS.Automation;

public class ContactScoreData : IAutomationProcessData
{
    public static string Identifier => "Acme.ContactScoreData";

    public int Score { get; set; }

    public DateTime ScoredAt { get; set; }
}
```

**Action 1** – Calculates and stores a score for the processed contact:

```csharp title="ScoreContactAction"
using System;
using System.Threading;
using System.Threading.Tasks;

using CMS.Automation;
using CMS.ContactManagement;
using Kentico.Xperience.Admin.Base;

using Acme.Automation;

[assembly: RegisterAutomationAction<ScoreContactAction>(
    identifier: ScoreContactAction.IDENTIFIER,
    displayName: "Score contact",
    Description = "Calculates and stores a score for the contact.",
    IconName = Icons.StarFull)]

namespace Acme.Automation;

public class ScoreContactAction : AutomationAction
{
    public const string IDENTIFIER = "Acme.ScoreContact";

    // Represents a custom service that calculates a score for a contact based on its activities and properties
    private readonly IContactScoringService scoringService;

    public ScoreContactAction(IContactScoringService scoringService)
    {
        this.scoringService = scoringService;
    }

    public override async Task Execute(AutomationProcessContext context, CancellationToken cancellationToken)
    {
        ContactInfo contact = await context.GetProcessedObject(cancellationToken);

        int score = await scoringService.CalculateScoreAsync(contact, cancellationToken);

        // Stores the score for downstream steps
        await context.SetProcessData(new ContactScoreData
        {
            Score = score,
            ScoredAt = DateTime.UtcNow
        }, cancellationToken);
    }
}
```

**Action 2** – Reads the score stored by earlier steps and performs an action based on its value:

```csharp title="NotifyByScoreAction"
using System.Threading;
using System.Threading.Tasks;

using CMS.Automation;
using CMS.ContactManagement;
using Kentico.Xperience.Admin.Base;

using Microsoft.Extensions.Logging;

using Acme.Automation;

[assembly: RegisterAutomationAction<NotifyByScoreAction>(
    identifier: NotifyByScoreAction.IDENTIFIER,
    displayName: "Notify based on score",
    Description = "Notifies the sales team if the processed contact surpasses a score threshold.",
    IconName = Icons.ArrowSend)]

namespace Acme.Automation;

public class NotifyByScoreAction : AutomationAction
{
    private const int HIGH_SCORE_THRESHOLD = 80;

    // Represents a custom service that sends notifications to the sales team
    private readonly INotificationService notificationService;

    private readonly ILogger<NotifyByScoreAction> logger;

    public NotifyByScoreAction(ILogger<NotifyByScoreAction> logger, INotificationService notificationService)
    {
        this.logger = logger;
        this.notificationService = notificationService;
    }

    public override async Task Execute(AutomationProcessContext context, CancellationToken cancellationToken)
    {
        // Gets the contact score calculated by previous steps from the process data
        ContactScoreData scoreData = await context.GetProcessData<ContactScoreData>(cancellationToken);

        if (scoreData is null)
        {
            logger.LogWarning("No score data found in the process context.");
            return;
        }

        ContactInfo contact = await context.GetProcessedObject(cancellationToken);

        if (scoreData.Score >= HIGH_SCORE_THRESHOLD)
        {
            // High-scoring contact - notify the sales team
            await notificationService.NotifySalesTeamAsync(contact, scoreData.Score, cancellationToken);
            logger.LogInformation("High-scoring contact {ContactName} (score: {Score}) routed to sales.",
                contact.ContactDescriptiveName, scoreData.Score);
        }
        else
        {
            logger.LogInformation("Contact {ContactName} scored {Score}, below threshold.",
                contact.ContactDescriptiveName, scoreData.Score);
        }
    }
}
```

## Best practices

The following best practices apply to **both custom actions and custom conditions** unless stated otherwise.

### Performance

- Keep the `Execute` method of actions and the `Evaluate` method of conditions lightweight – automation processes can run for a large number of contacts on high-traffic sites.
- If your steps retrieve data from the Xperience database or another source, cache any data that is safe to reuse using `IProgressiveCache` or an equivalent caching mechanism. See [Data caching](https://docs.kentico.com/documentation/developers-and-admins/development/caching/data-caching.md) to learn more.
  - Make sure you set sufficiently unique cache keys and use appropriate [cache dependencies](https://docs.kentico.com/documentation/developers-and-admins/development/caching/cache-dependencies.md).

### Execution timeout

Custom steps have a maximum execution time of **2 minutes**. If a step does not complete within this limit, the system triggers the `CancellationToken` passed to the `Execute` or `Evaluate` method.

After cancellation is requested, a **30-second** grace period begins. Once the grace period expires, the automation process continues regardless of whether the step has finished. Any operations still running after the grace period continue in the background without blocking the process.

- For an **action** that times out, the contact moves to the next step.
- For a **condition** that times out, the system treats the result as `false` and the process follows the false branch.

Always propagate the cancellation token to all async calls within your steps to ensure timely termination.

Design steps to complete quickly. If long-running operations are unavoidable, enqueue work to an external queue or background service and return immediately.

### Error handling

- Handle expected errors (API timeouts, network failures) gracefully and [log](https://docs.kentico.com/documentation/developers-and-admins/development/logging.md) them.
- Always respect the `CancellationToken` parameter – the system cancels execution during application shutdown or when the [timeout](#execution-timeout) is reached.
- Unhandled exceptions are isolated so they cannot stop the process, but the outcome differs by step type:
  - For an **action**, execution ends and the contact moves to the next step.
  - For a **condition**, the system logs the failure, treats the condition as `false`, and the process follows the false branch. Design the false branch as the safe default path.

```csharp title="Error handling example"
using CMS.Automation;
using CMS.ContactManagement;

using Microsoft.Extensions.Logging;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;

public class MyAction(ILogger<MyAction> logger, 
                      IExternalAPI externalApi) : AutomationAction()
{    
    public override async Task Execute(AutomationProcessContext context, CancellationToken cancellationToken)
    {
        try
        {
            await externalApi.CallAsync(cancellationToken);
        }
        catch (HttpRequestException ex) when (!cancellationToken.IsCancellationRequested)
        {
            logger.LogError(ex, "External API call failed for contact with ID: {ContactId}.",
                (await context.GetProcessedObject(cancellationToken)).ContactID);
        }

        // Remaining Execute code continues
    }
}
```

### Data protection (GDPR)

- If you store personal data (names, emails, phone numbers) within `IAutomationProcessData` implementations, keep the following in mind:
  - When a [personal data erasure](https://docs.kentico.com/documentation/developers-and-admins/data-protection/personal-data-erasure.md) implementation deletes a contact (`ContactInfo`), the corresponding automation process history and any stored data is also deleted automatically.
  - The system does not currently provide a built-in mechanism to export process data as part of data protection "right to portability" workflows.
- Avoid persisting personal or sensitive data (non-employee email addresses, API keys, tokens) within [step, condition, or trigger properties](#step-properties).

### Updating and versioning custom steps

- Never change the `identifier` of a registered action or condition – doing so breaks all processes that reference the step.
- Adding new optional [properties](#step-properties) to a properties class is safe – they receive default values for existing step configurations.
- Removing or renaming properties breaks existing step configurations.
  - If you need to remove or replace a property without breaking existing configurations, you can remove the property's UI form component attribute to hide it in the configuration dialog.

To version a step safely:

1. Create a new action or condition class with a new identifier (e.g., with a `_V2` suffix), along with a new properties class if required.
2. Keep the old version registered and functional.
3. Instruct marketers to only use the new version, for example by updating the old version's display name and description.
4. Once all processes using the old version are disabled and no longer relevant, the old classes can safely be removed.

> **Note:** When disabling a process, remember that contacts already in the process (including those in _Wait_ steps) will continue progressing through existing steps.

### Condition guidelines

The following recommendations apply specifically to custom conditions:

- Keep conditions **read-only and free of side effects** – evaluate and return a result without modifying contacts or storing [process data](#share-data-between-automation-steps). Perform side-effecting work in a preceding **action** instead.
- Make conditions **deterministic and idempotent** – a condition may be re-evaluated as contacts move through the process, so repeated evaluations with the same data should return the same result.
- Design the **false branch as the safe default** – if a condition fails or times out, the process follows the false branch.
- Keep evaluation fast, following the [Performance](#performance) and [Execution timeout](#execution-timeout) guidance.

### Property guidelines

The following recommendations help you keep your [step, condition, or trigger properties](#step-properties) organized and make it easier for marketers to configure automation components:

- Group properties into logical categories with class-level `FormCategory` attributes.
  - Order the categories based on their importance to create clear configuration blocks.
- Set `Label`, `ExplanationText`, and `Order` values for every UI form component attribute.
  - Keep `ExplanationText` brief and practical. Avoid redundant explanations.
  - Keep property order values consistent with category order and expected editor flow.
  - Use `Order` values with increments of 100 for categories and increments of 10 for properties to allow future inserts without renumbering.
- Property order in the code file should always match the sequence of `Order` property values in component attributes.
- Place the main UI form component attribute first for each property, above visibility, validation and other attributes.
- If using multiple short visibility attributes, keep them on one line (for example `VisibleIfTrue`, `VisibleIfEqualTo`).
- Use multiline formatting for component attributes with multiple or long arguments.
- When setting [default property values](#set-default-property-values):
  - For properties with collection types, prefer empty collection defaults using collection expressions (for example `= []`).
  - Prefer `string.Empty` initial string values over `null!` for default values if a real default value is not specified.

### Dependency injection

Actions and conditions are resolved from the DI container. Use constructor injection to obtain services:

```csharp
using Microsoft.Extensions.Logging;
using System.Threading;
using System.Threading.Tasks;

using CMS.Automation;
using CMS.Helpers;

public class MyAction : AutomationAction
{
    private readonly ILogger<MyAction> logger;
    private readonly ICrmClient crmClient;
    private readonly IProgressiveCache cache;

    public MyAction(ILogger<MyAction> logger, ICrmClient crmClient, IProgressiveCache cache)
    {
        this.logger = logger;
        this.crmClient = crmClient;
        this.cache = cache;
    }

    public override async Task Execute(AutomationProcessContext context, CancellationToken cancellationToken)
    {
        // All injected services are available
        // ...
    }
}
```
