jfb-form-action

Registers a custom JetFormBuilder Form Action — a server-side handler that runs after submit (send to API, subscribe to CRM, write to a sheet, etc.). Covers extending Base action class, declaring settings via action_attributes(), implementing do_action() with Action_Exception error reporting, building the action-editor React panel via window.JetFBActions.addAction(), the two field-mapping patterns (dynamic "Add row" like Google Sheets vs fixed-key pattern like Fluent CRM with predefined target fields), looking up the form's current fields via the useFields() hook, multi-select with FormLabeledTokenField, plus the category/docHref convention. Use when scaffolding a JFB integration plugin (Mailchimp, Slack, custom API, payment processor, CRM subscribe). Triggers on mentions of "JFB action", "jet-form-builder/actions/register", "Base_Action", "JetFBActions.addAction", "Action_Exception", "field map", or "fields_map".

Best use case

jfb-form-action is best used when you need a repeatable AI agent workflow instead of a one-off prompt.

Registers a custom JetFormBuilder Form Action — a server-side handler that runs after submit (send to API, subscribe to CRM, write to a sheet, etc.). Covers extending Base action class, declaring settings via action_attributes(), implementing do_action() with Action_Exception error reporting, building the action-editor React panel via window.JetFBActions.addAction(), the two field-mapping patterns (dynamic "Add row" like Google Sheets vs fixed-key pattern like Fluent CRM with predefined target fields), looking up the form's current fields via the useFields() hook, multi-select with FormLabeledTokenField, plus the category/docHref convention. Use when scaffolding a JFB integration plugin (Mailchimp, Slack, custom API, payment processor, CRM subscribe). Triggers on mentions of "JFB action", "jet-form-builder/actions/register", "Base_Action", "JetFBActions.addAction", "Action_Exception", "field map", or "fields_map".

Teams using jfb-form-action should expect a more consistent output, faster repeated execution, less prompt rewriting.

When to use this skill

  • You want a reusable workflow that can be run more than once with consistent structure.

When not to use this skill

  • You only need a quick one-off answer and do not need a reusable workflow.
  • You cannot install or maintain the underlying files, dependencies, or repository context.

Installation

Claude Code / Cursor / Codex

$curl -o ~/.claude/skills/jfb-form-action/SKILL.md --create-dirs "https://raw.githubusercontent.com/nvdigitalsolutions/mcp-ai-wpoos/main/addons/pro/includes/bundled-skills/jfb-form-action/SKILL.md"

Manual Installation

  1. Download SKILL.md from GitHub
  2. Place it in .claude/skills/jfb-form-action/SKILL.md inside your project
  3. Restart your AI agent — it will auto-discover the skill

How jfb-form-action Compares

Feature / Agentjfb-form-actionStandard Approach
Platform SupportNot specifiedLimited / Varies
Context Awareness High Baseline
Installation ComplexityUnknownN/A

Frequently Asked Questions

What does this skill do?

Registers a custom JetFormBuilder Form Action — a server-side handler that runs after submit (send to API, subscribe to CRM, write to a sheet, etc.). Covers extending Base action class, declaring settings via action_attributes(), implementing do_action() with Action_Exception error reporting, building the action-editor React panel via window.JetFBActions.addAction(), the two field-mapping patterns (dynamic "Add row" like Google Sheets vs fixed-key pattern like Fluent CRM with predefined target fields), looking up the form's current fields via the useFields() hook, multi-select with FormLabeledTokenField, plus the category/docHref convention. Use when scaffolding a JFB integration plugin (Mailchimp, Slack, custom API, payment processor, CRM subscribe). Triggers on mentions of "JFB action", "jet-form-builder/actions/register", "Base_Action", "JetFBActions.addAction", "Action_Exception", "field map", or "fields_map".

Where can I find the source code?

You can find the source code on GitHub using the link provided at the top of the page.

SKILL.md Source

# JetFormBuilder: register a custom Form Action

A Form Action is a server-side handler that fires **after** a JFB form is submitted: send email, subscribe to a CRM, append to a Google Sheet, post to Slack, charge a card, etc. Each form can have multiple actions in a chain; each action sees the request data and the chain context, can write back to the response, and can halt the chain by throwing.

This skill covers the official end-to-end API as observed in JFB 3.5.x and three production companion plugins. It includes both field-mapping patterns plugins use in the wild (dynamic "Add row" and fixed-key), plus the multi-select / token-field pattern.

## API stability note

The `Base` action class, the `'jet-form-builder/actions/register'` PHP hook, the persistence model (`_jf_actions` post meta), and the `Action_Exception` error pattern have been stable across JFB 3.x. The JS-side `window.JetFBActions.addAction()` and `window.jfb.blocksToActions.useFields()` are also stable in the source observed. The `category` and `docHref` action editor properties are a **community convention** (used by Fluent CRM and others) that JFB's action picker UI consumes, but they are not part of the PHP API — keep them in JS only.

## When to use this skill

- Building a JFB companion plugin that needs to do something on form submit.
- The user mentions an integration target (CRM, payment, webhook, file storage, ticketing).
- The diff/files contain `extends Base` (action namespace), `action_attributes`, `do_action`, `JetFBActions.addAction`, `useFields`, `Action_Exception`, or `'jet-form-builder/actions/register'`.

## Architecture in one paragraph

JFB's action manager fires `'jet-form-builder/actions/register'` at `init` priority 99 with a `Manager` instance. Each plugin calls `$manager->register_action_type( new MyAction() )` with a class extending `Jet_Form_Builder\Actions\Types\Base`. The class declares its settings shape in `action_attributes()`, exposes user-facing labels via `editor_labels()` / `editor_labels_help()`, and implements `do_action( array $request, Action_Handler $handler )` which runs at submit time. On the JS side, the action editor is React-based: the plugin enqueues a script on `'jet-form-builder/editor-assets/before'` and calls `window.JetFBActions.addAction( id, Component, config )` to register the editor UI. The component receives `props.settings` and `props.onChangeSetting`, uses `window.jfb.blocksToActions.useFields()` to query the form's current fields, and renders standard `@wordpress/components`. The user's configuration persists as part of the form's `_jf_actions` post meta (a JSON array of all actions on the form). Errors during execution are surfaced by throwing `Action_Exception( $code, $message )`.

## Step 1 — PHP: extend the Base action class

```php
<?php
namespace MyPlugin\Actions;

use Jet_Form_Builder\Actions\Types\Base;
use Jet_Form_Builder\Actions\Action_Handler;
use Jet_Form_Builder\Exceptions\Action_Exception;

class SubscribeAction extends Base {

    public function get_id() {
        return 'myplugin_subscribe';
    }

    public function get_name() {
        return __( 'My CRM Subscribe', 'myplugin' );
    }

    public function self_script_name() {
        // Used as the localize handle in JS — must be unique.
        return 'MyPluginSubscribeData';
    }

    public function action_attributes() {
        return array(
            'list_id'    => array( 'default' => array() ),       // multi-select target
            'tag_ids'    => array( 'default' => array() ),       // multi-select tags
            'fields_map' => array( 'default' => array(           // fixed-key mapping
                'email'      => '',
                'first_name' => '',
                'last_name'  => '',
            ) ),
            'double_optin'         => array( 'default' => true ),
            'duplicate_message'    => array( 'default' => '' ),
        );
    }

    public function editor_labels() {
        return array(
            'list_id'    => __( 'Lists',        'myplugin' ),
            'tag_ids'    => __( 'Tags',         'myplugin' ),
            'fields_map' => __( 'Field map',    'myplugin' ),
            'email'      => __( 'Email field',  'myplugin' ),
            'first_name' => __( 'First name',   'myplugin' ),
            'last_name'  => __( 'Last name',    'myplugin' ),
        );
    }

    public function editor_labels_help() {
        return array(
            'email' => __( 'The form field that holds the subscriber email address.', 'myplugin' ),
        );
    }

    public function action_data() {
        // Pushed to JS as MyPluginSubscribeData (or whatever self_script_name returns).
        return array(
            'lists'    => $this->get_remote_lists(),
            'tags'     => $this->get_remote_tags(),
            'field_map' => array(
                array( 'key' => 'email',      'label' => __( 'Email field',      'myplugin' ), 'required' => true ),
                array( 'key' => 'first_name', 'label' => __( 'First name field', 'myplugin' ) ),
                array( 'key' => 'last_name',  'label' => __( 'Last name field',  'myplugin' ) ),
            ),
        );
    }

    public function do_action( array $request, Action_Handler $handler ) {
        $email = $this->get_mapped_value( 'email', $request );
        if ( ! is_email( $email ) ) {
            throw new Action_Exception( 'failed', __( 'A valid email is required.', 'myplugin' ) );
        }

        $payload = array(
            'email'      => $email,
            'first_name' => $this->get_mapped_value( 'first_name', $request ),
            'last_name'  => $this->get_mapped_value( 'last_name', $request ),
            'lists'      => (array) ( $this->settings['list_id'] ?? array() ),
            'tags'       => (array) ( $this->settings['tag_ids'] ?? array() ),
        );

        $result = $this->call_remote_api( $payload );
        if ( is_wp_error( $result ) ) {
            throw new Action_Exception( 'failed', $result->get_error_message() );
        }

        // Hand off to later actions if useful.
        $handler->add_context_once( array(
            'subscriber_id' => $result['id'] ?? '',
        ) );
    }

    private function get_mapped_value( string $key, array $request ): string {
        $map      = (array) ( $this->settings['fields_map'] ?? array() );
        $field_id = (string) ( $map[ $key ] ?? '' );
        if ( '' === $field_id ) {
            return '';
        }
        $value = $request[ $field_id ] ?? '';
        if ( is_array( $value ) ) {
            $value = reset( $value ) ?: '';
        }
        return is_scalar( $value ) ? (string) $value : '';
    }
}
```

Required methods: `get_id`, `get_name`, `do_action`. Strongly recommended: `action_attributes`, `editor_labels`, `self_script_name`. Optional: `action_data`, `editor_labels_help`, `dependence`, `unsupported_events`.

`get_id()` MUST be a stable, unique slug — changing it after release strands all existing user configurations. Treat it as a public identifier.

## Step 2 — PHP: register the action

```php
add_action(
    'jet-form-builder/actions/register',
    function ( $manager ) {
        $manager->register_action_type( new \MyPlugin\Actions\SubscribeAction() );
    }
);
```

The hook fires once per request at `init` priority 99 with a `Jet_Form_Builder\Actions\Manager` instance. Don't store the action somewhere else — register it here and let JFB own the lifecycle.

## Step 3 — PHP: enqueue the action editor JS

```php
add_action(
    'jet-form-builder/editor-assets/before',
    function () {
        $handle = 'myplugin-action-editor';

        $dependencies = array(
            'jet-fb-components', // exposes window.jfb.components and window.jfb.blocksToActions
            'wp-element',        // React via @wordpress/element
            'wp-components',     // SelectControl, TextControl, ToggleControl, etc.
            'wp-i18n',
        );

        // JFB v2 action editor handles. Conditionally added — they are
        // only registered when the modern action editor is active.
        // Without these, your script can race the modern API and run
        // before window.jfb.actions is populated, causing dual-registration
        // failures and editor crashes (the JSON.parse "[object Object]"
        // error in form.builder.js's _jf_gateways meta state is a known
        // symptom).
        foreach ( array( 'jet-fb-actions-v2', 'jet-fb-blocks-v2-to-actions-v2' ) as $maybe_dep ) {
            if ( wp_script_is( $maybe_dep, 'registered' ) ) {
                $dependencies[] = $maybe_dep;
            }
        }

        wp_enqueue_script(
            $handle,
            plugins_url( 'assets/js/action-editor.js', __FILE__ ),
            $dependencies,
            '1.0.0',
            true
        );

        if ( function_exists( 'wp_set_script_translations' ) ) {
            wp_set_script_translations( $handle, 'myplugin' );
        }
    }
);
```

`jet-fb-components` is the script handle that exposes `window.jfb.blocksToActions.useFields()`, `window.jfb.components.*`, and `window.JetFBActions.addAction()`. Always declare it.

`jet-fb-actions-v2` and `jet-fb-blocks-v2-to-actions-v2` are the modern action editor v2 handles. They are only registered on JFB versions that ship the v2 editor. Add them conditionally with `wp_script_is( $handle, 'registered' )` — never declare them unconditionally, because on older JFB versions they don't exist and the dependency will silently break script loading.

## Step 4 — JS: register the action editor

JFB has **two action registration APIs** in the wild and both can be present at the same time:

- **Modern**: `window.jfb.actions.registerAction({ type, edit, ...config })` — the v2 action editor.
- **Legacy**: `window.JetFBActions.addAction( type, component, config )` — the older wp.data-store-driven flow, plus `wp.data.dispatch('jet-forms/actions').registerAction(...)` for category/docHref metadata.

**Critical rule: register through ONE path only — never both.** Calling `addAction()` AND `wp.data.dispatch('jet-forms/actions').registerAction(...)` AND `jfb.actions.registerAction(...)` together corrupts the editor's data store and produces baffling failures (the `JSON.parse "[object Object]"` crash on `_jf_gateways` meta in `form.builder.js` is a known symptom of dual registration on JFB versions where both APIs co-exist).

The recommended pattern is a **wrapper that prefers modern, falls back to legacy**:

```js
( function registerMyPluginAction( wp, JetFBActions, actionData, jfb ) {
    if ( ! wp ) {
        return;
    }

    const hasModernAction = jfb?.actions && typeof jfb.actions.registerAction === 'function';
    const hasLegacyAction = JetFBActions && typeof JetFBActions.addAction === 'function';

    if ( ! hasModernAction && ! hasLegacyAction ) {
        // Neither API available — JFB editor not loaded. Fail silently.
        return;
    }

    const addAction = hasLegacyAction
        ? JetFBActions.addAction.bind( JetFBActions )
        : () => {};

    const { Fragment, createElement } = wp.element;
    const { __ } = wp.i18n;
    const { TextControl, ToggleControl, Button, Notice } = wp.components;

    const useFields     = jfb?.blocksToActions?.useFields;
    const jfbComponents = jfb?.components || {};

    // ONE entry point that picks the right API at runtime.
    const registerAction = ( type, component, config ) => {
        if ( hasModernAction ) {
            jfb.actions.registerAction( {
                type,
                edit: component,
                ...config,
            } );
            return;
        }
        addAction( type, component, config );
    };

    function MyPluginSubscribeEdit( props ) {
        const { settings = {}, onChangeSetting, label } = props;
        const data      = actionData || {};
        const fieldOpts = useFields ? useFields( { withInner: false, placeholder: '--' } ) : [];

        // ... field mapping, multi-select, etc. (Step 5)

        return createElement( Fragment, null /* children */ );
    }

    registerAction(
        'myplugin_subscribe', // MUST match get_id() in PHP
        MyPluginSubscribeEdit,
        {
            category:      'communication',
            docHref:       'https://example.com/docs/myplugin-subscribe',
            provideEvents: () => [ 'MYPLUGIN.SUCCESS', 'MYPLUGIN.FAILURE' ], // see jfb-action-events
        }
    );
}( window.wp || false, window.JetFBActions || false, window.MyPluginSubscribeData || {}, window.jfb || {} ) );
```

Notes on the wrapper:

- The IIFE pattern (immediate-invocation with `window.*` arguments) keeps the module self-contained and lets any of the dependencies be missing without throwing — important because `window.jfb` and `window.JetFBActions` may be undefined on older JFB versions or if the editor failed to load.
- `hasModernAction` and `hasLegacyAction` are checked once; the wrapper picks one path and never switches mid-call.
- The modern API wraps the component in `edit`, the legacy passes it positionally — abstract this away in the wrapper so the rest of your code is API-agnostic.
- **Don't add a separate `wp.data.dispatch('jet-forms/actions').registerAction(...)` call alongside this**. Older skills suggested it as a "data store mirror" — that advice is obsolete and creates the dual-registration bug. The modern API handles store registration internally; the legacy path's `addAction` is sufficient on its own.

If neither API is available (`! hasModernAction && ! hasLegacyAction`), your script is loaded but the JFB editor isn't — bail silently. Don't render an error UI.

## Step 5 — Field mapping: pick the right pattern

Two patterns are common and serve different needs.

### 5a. Fixed-key mapping (preferred when target schema is known)

Use when your integration has a fixed set of target fields (CRM with first_name/last_name/email/phone). The user maps form fields to these named slots.

```js
const fieldMapDef = data.field_map || []; // from PHP action_data()
const currentMap  = settings.fields_map || {};

const handleMap = ( key, value ) => {
    onChangeSetting( { ...currentMap, [ key ]: value }, 'fields_map' );
};

// JFB ships an optional table layout — check if available, fall back to plain controls.
const Table = jfbComponents.TableListContainer;
const Head  = jfbComponents.TableListHead;
const Row   = jfbComponents.TableListRow;

const renderRows = fieldMapDef.map( ( def ) =>
    el( Row, {
        key:        def.key,
        tag:        def.key,
        label:      def.label,
        help:       def.help,
        isRequired: !! def.required,
    },
        ( ) => el( StyledSelect, {
            value:    currentMap[ def.key ] || '',
            options:  fieldOpts,
            onChange: ( v ) => handleMap( def.key, v ),
        } )
    )
);

const fixedMapUI = ( Table && Head && Row )
    ? el( Table, null,
        el( Head, { columns: [ __( 'Target field', 'myplugin' ), __( 'Form field', 'myplugin' ) ] } ),
        renderRows
      )
    : el( Fragment, null,
        fieldMapDef.map( ( def ) =>
            el( StyledSelect || 'select', {
                key:      def.key,
                label:    def.label,
                value:    currentMap[ def.key ] || '',
                options:  fieldOpts,
                onChange: ( v ) => handleMap( def.key, v ),
            } )
        )
      );
```

PHP shape: `'fields_map' => [ 'email' => 'form_email_id', 'first_name' => 'form_fname_id', ... ]`.

### 5b. Dynamic "Add row" mapping (use when the target schema is open-ended)

Use when the target system has arbitrary columns / properties and the user picks both sides — e.g. Google Sheets where the user types arbitrary column names.

```js
const rows = Array.isArray( settings.field_map ) ? settings.field_map : [];

const updateRow = ( i, key, value ) => {
    onChangeSetting(
        rows.map( ( r, idx ) => idx === i ? { ...r, [ key ]: value } : r ),
        'field_map'
    );
};
const addRow = () => onChangeSetting(
    rows.concat( [ { column_header: '', form_field: '' } ] ),
    'field_map'
);
const removeRow = ( i ) => onChangeSetting(
    rows.filter( ( _, idx ) => idx !== i ),
    'field_map'
);

const dynamicMapUI = el( 'div', { className: 'myplugin-mapping' },
    rows.map( ( row, i ) => el( 'div', { key: i, className: 'myplugin-mapping-row' },
        el( TextControl, {
            label:    __( 'Column', 'myplugin' ),
            value:    row.column_header,
            onChange: ( v ) => updateRow( i, 'column_header', v ),
        } ),
        el( StyledSelect || 'select', {
            label:    __( 'Form field', 'myplugin' ),
            value:    row.form_field,
            options:  fieldOpts,
            onChange: ( v ) => updateRow( i, 'form_field', v ),
        } ),
        el( Button, {
            isDestructive: true,
            isSmall:       true,
            onClick:       () => removeRow( i ),
        }, __( 'Remove', 'myplugin' ) )
    ) ),
    el( Button, { variant: 'secondary', onClick: addRow },
        __( 'Add row', 'myplugin' )
    )
);
```

PHP shape: `'field_map' => [ [ 'column_header' => 'Email',  'form_field' => 'email_field_id' ], ... ]`.

### 5c. Multi-select (lists, tags, channels)

For multi-value selects (e.g. CRM lists or tags), use JFB's `FormLabeledTokenField` if available, fall back to `SelectControl` with `multiple: true`.

```js
const lists           = data.lists || []; // [{ value, label }]
const selectedListIds = ( settings.list_id || [] ).map( String );

// FormLabeledTokenField (JFB-shipped) handles value↔label translation
// internally: pass IDs as `value`, pass {value, label} objects as
// `suggestions`, and onChange receives IDs back. This is unlike the
// standard @wordpress/components FormTokenField, which is string-only.
const handleListTokens = ( tokens ) => {
    onChangeSetting( ( tokens || [] ).map( String ), 'list_id' );
};

const listsUI = FormLabeledTokenField
    ? el( FormLabeledTokenField, {
        label:                          __( 'Lists', 'myplugin' ),
        value:                          selectedListIds,
        suggestions:                    lists, // array of { value, label }
        onChange:                       handleListTokens,
        __experimentalExpandOnFocus:    true,
      } )
    : el( StyledSelect || 'select', {
        label:    __( 'Lists', 'myplugin' ),
        value:    selectedListIds,
        options:  lists,
        multiple: true,
        onChange: ( v ) => onChangeSetting( ( v || [] ).map( String ), 'list_id' ),
      } );
```

`FormLabeledTokenField` is a token (chip) input that feels native in Gutenberg admin. Note its API differs from the standard `@wordpress/components/FormTokenField`: the labeled variant accepts `{ value, label }` suggestions and translates between displayed labels and stored IDs internally — you store IDs, the user sees labels. The fallback path uses the plain string-based `SelectControl`. Always include the fallback; the `FormLabeledTokenField` component is shipped by JFB and not guaranteed across versions.

## `useFields` — the form fields hook

`window.jfb.blocksToActions.useFields( opts )` is a React hook returning the form's current fields as `[ { value, label }, ... ]`, suitable for any select control. Common options:

- `withInner` (boolean) — include nested fields (repeater children, etc.). Default: depends on JFB version; pass explicitly.
- `placeholder` (string) — first option label, e.g. `'--'`. If passed, prepends `{ value: '', label: placeholder }`.

There is no REST call here; it's local React state derived from the editor's current form blocks. The hook re-runs when fields change, so the dropdown reflects edits in real time.

## `provideEvents` — advertise custom events the action dispatches

If your action dispatches custom JFB events (defined per the **`jfb-action-events`** skill via the `'jet-form-builder/event-types'` filter), you MUST advertise them in the JS registration. Otherwise the events exist in the global registry but are invisible in the per-action event picker — meaning no admin can wire any action to them.

Pass `provideEvents` in the same `config` object you give the `registerAction` wrapper (Step 4):

```js
registerAction(
    'myplugin_subscribe',
    Component,
    {
        category:      'communication',
        docHref:       'https://example.com/docs/subscribe',
        provideEvents: ( settings ) => [
            'MYPLUGIN.SUCCESS',
            'MYPLUGIN.ALREADY_EXISTS',
        ],
    }
);
```

The callback receives the action's current settings — return a different list per configuration if needed (e.g. only advertise `'MYPLUGIN.FAILURE'` when error notifications are enabled). The wrapper passes this through to whichever underlying API is active (`jfb.actions.registerAction` or `JetFBActions.addAction`); both honour `provideEvents`. See **`jfb-action-events`** for the full visibility model (always / gateway / dynamic / provided).

## Category and doc link — community convention, not PHP API

The `category` and `docHref` properties passed in the registration config are **JS-only conventions** consumed by JFB's action picker UI for grouping and rendering a help icon. They are **not exposed by the PHP `Base` class** — there is no `get_category()` or `get_doc_link()` method. The Fluent CRM plugin pioneered this; if you follow the same shape, your action will group and link consistently with theirs.

```js
registerAction( 'myplugin_subscribe', Component, {
    category: 'communication', // free-form; common values: 'communication', 'integration', 'advanced', 'utility'
    docHref:  'https://example.com/docs/...',
} );
```

**Do NOT add a separate `wp.data.dispatch('jet-forms/actions').registerAction(...)` call alongside this.** Older versions of this skill recommended that pattern as a "data store mirror", but on JFB versions that ship the modern `jfb.actions.registerAction` API, the dual call corrupts the editor's data store and triggers `JSON.parse "[object Object]"` failures in `form.builder.js`. The wrapper from Step 4 already routes to whichever API is available, and that single call is enough — both APIs internally write to the data store.

Don't push category/docHref into PHP `action_data()` expecting JFB to render it — there's no consumer there. Keep it JS-side.

## Native components and JFB-shipped helpers

Use these directly:

| Source | Component | Purpose |
|---|---|---|
| `@wordpress/components` | `TextControl`, `TextareaControl`, `ToggleControl`, `CheckboxControl`, `SelectControl`, `RadioControl`, `RangeControl`, `Button`, `Notice`, `PanelBody`, `Spinner` | Standard Gutenberg controls — the same set as `jfb-form-sidebar-panel`. |
| `window.jfb.components` | `StyledSelect` | Styled wrapper around `SelectControl` matching JFB action editor look. |
| `window.jfb.components` | `FormLabeledTokenField` | Token / chip input for multi-value selects. |
| `window.jfb.components` | `TableListContainer`, `TableListHead`, `TableListRow`, `TableListStyle` | Table layout helpers for fixed-key mapping. |
| `window.jfb.blocksToActions` | `useFields` | React hook returning the form's current fields. |
| `window.jfb.actions` | `registerAction({ type, edit, ...config })` | **Modern** action registration API. Single object argument with `edit` for the component. |
| `window.JetFBActions` | `addAction( type, component, config )` | **Legacy** action registration API. Positional args. Use the wrapper from Step 4 to bridge both. |

Always check existence before using JFB-shipped components — they are not part of a documented stable contract, and a future JFB version may move them. A graceful fallback to `wp.components` keeps the editor usable across versions.

## Error handling: `Action_Exception`

Throw from `do_action()` to halt the action chain and surface a message to the user:

```php
throw new \Jet_Form_Builder\Exceptions\Action_Exception( 'failed', __( 'CRM unreachable. Try again later.', 'myplugin' ) );
```

The first argument is a stable code (used for conditional logic / logging), the second is the user-facing message (translatable). The handler catches the exception, marks the action as failed, includes the message in the form response, and stops processing further actions.

For non-fatal issues (e.g. "user already subscribed — that's fine"), don't throw; just `return` from `do_action()` after writing context.

## Action context: passing data between actions

```php
$handler->add_context_once( array( 'subscriber_id' => 42 ) );
```

Later actions in the chain (or follow-up handlers) read this via:

```php
$id = $handler->get_action_handler_context( 'myplugin_subscribe' )['subscriber_id'] ?? null;
// or via the helper
$id = jet_fb_action_handler()->get_inserted_post_id(); // for built-in patterns
```

Use this when one action's output is another's input (e.g. "Insert Post" → ID → "Send Email" with the post URL).

## Storage: `_jf_actions` post meta

JFB persists all of a form's actions under one post-meta key on the form CPT (`jet-form-builder`):

```
post_id  | meta_key      | meta_value (JSON-encoded)
123      | _jf_actions   | [ { "id": 0, "type": "send_email", "settings": {...} },
                              { "id": 1, "type": "myplugin_subscribe", "settings": {...} } ]
```

You don't need to read or write this directly — JFB handles the round-trip. If you need to programmatically configure an action (e.g. for a setup wizard), update this meta value via the standard post meta API.

## Critical rules

- **`get_id()` is a public identifier** — never change it after release. Migrating users to a new ID requires writing a meta upgrader.
- **Register on `'jet-form-builder/actions/register'`**, not on `init` directly. The Manager isn't ready earlier.
- **`do_action()` is request-scoped** — don't cache state on `$this` between submissions. Each form submit instantiates fresh.
- **Throw `Action_Exception`** for halting errors. Never `wp_die()` or `exit` from `do_action()` — you'll break the JFB response handling.
- **`add_context_once` over `add_context`** when writing data for downstream actions — prevents accidental overwrites if your action somehow runs twice.
- **Fixed-key vs dynamic mapping**: don't mix shapes. Pick one based on whether the target schema is known and stable.
- **`useFields` hook** must be called inside the component render, not at module top level — it's a React hook.
- **`category` and `docHref` live in JS only** — don't try to surface them through the PHP API.
- **Always provide `self_script_name()`** if you call `wp_localize_script` with action data — the name must be unique across all actions on the page.
- **`is_email()`, `absint()`, `sanitize_text_field()`** — sanitize values inside `do_action()` before passing to APIs. The form data is post-validation by JFB but post-validation ≠ post-sanitization for your specific target.
- **Register through ONE API only** — modern `jfb.actions.registerAction` OR legacy `JetFBActions.addAction`, never both, and never duplicate via `wp.data.dispatch('jet-forms/actions').registerAction`. Use the wrapper from Step 4 to make a single call route to the right path. Dual registration corrupts the editor's data store on JFB versions that ship both APIs.
- **Conditionally depend on `jet-fb-actions-v2` and `jet-fb-blocks-v2-to-actions-v2`** in `wp_enqueue_script` — only when `wp_script_is($handle, 'registered')` confirms the modern editor is present. Hard-coding these as unconditional dependencies breaks on older JFB versions that don't ship them.

## Common pitfalls (failure modes inferred from the API contract)

- **Action doesn't appear in the action picker**: editor JS not enqueued (check `'jet-form-builder/editor-assets/before'` hook), OR registration ID doesn't match `get_id()`, OR neither `window.jfb.actions` nor `window.JetFBActions` is available (missing `jet-fb-components` and/or `jet-fb-actions-v2` dependency).
- **`JSON.parse "[object Object]"` crash in `form.builder.js` (the `_jf_gateways` meta state)**: classic symptom of dual action registration on JFB versions that ship both the modern and legacy APIs. Calling `addAction()` AND `wp.data.dispatch('jet-forms/actions').registerAction(...)` at the same time corrupts the data store. Switch to the single-call wrapper from Step 4.
- **Settings don't save**: action ID mismatch between PHP and JS, OR `onChangeSetting` called with the wrong key name (must match `action_attributes()` keys).
- **`useFields` returns empty**: called outside a React render, OR the form has no field blocks yet.
- **Form submit silently fails after adding the action**: thrown `Action_Exception` not caught — verify the namespace import. Or you're throwing a generic `Exception` instead of `Action_Exception`, which JFB doesn't translate to user-facing.
- **Multi-select stores wrong values**: when using the plain `@wordpress/components/FormTokenField` (string-based), `onChange` receives displayed labels, not IDs — translate manually. JFB's `FormLabeledTokenField` handles the translation internally and returns IDs directly (verified by production usage in `fluent-subscriptions-for-jetformbuilder`).
- **Field mapping resets on every load**: `action_attributes()` `default` is wrong shape (e.g. returning `''` for an array key).
- **Editor crashes only on certain forms / certain actions**: race condition. Your script ran before the v2 action editor was ready. Add `jet-fb-actions-v2` and `jet-fb-blocks-v2-to-actions-v2` as conditional dependencies (Step 3) so wp_enqueue_script defers loading.

## Cross-references

- Run **`jfb-form-sidebar-panel`** when the plugin also needs per-form settings outside of any action (e.g. media-storage's storage targets) — different subsystem.
- Run **`jfb-settings-tab`** for site-wide settings (API keys, defaults). Most action plugins have all three: a global tab for credentials, a sidebar panel for per-form overrides if any, and the action itself.
- Run **`wp-security-audit`** on `do_action()` — it processes user-submitted data and calls remote APIs. SSRF, sanitize, capability are all relevant.
- Run **`wp-security-secrets`** when API credentials are stored — never hardcode, never commit, prefer `wp-config.php` constants or capability-gated options.
- Run **`wp-i18n-audit`** on PHP labels and JS strings.

## What this skill does NOT cover

- The action's *event* model (`form_submit_event`, `gateway_failed_event`, etc.) — actions can declare which events they support; this skill assumes the default submit event.
- Actions that integrate with JFB's payment gateways subsystem — that's a separate API on top of actions.
- Conditional execution rules (the "execute only if X" UI on each action) — handled by JFB core, not the action class.
- Background / queued execution. Out of the box, actions run synchronously inside the submit request.
- Writing JFB CORE actions inside the JFB plugin itself (different namespacing rules).

## References

- Base class: `wp-content/plugins/jetformbuilder/includes/actions/types/base.php`
- Manager and registration hook: `wp-content/plugins/jetformbuilder/includes/actions/manager.php`
- Action handler / execution loop: `wp-content/plugins/jetformbuilder/includes/actions/action-handler.php`
- Built-in `Send_Email` example: `wp-content/plugins/jetformbuilder/modules/actions-v2/send-email/send-email-action.php`
- Dynamic-mapping reference (Google Sheets):
  `wp-content/plugins/google-sheet-for-jetformbuilder/includes/Action/GoogleSheetAction.php`
  `wp-content/plugins/google-sheet-for-jetformbuilder/assets/js/action-editor.js`
- Fixed-mapping + multi-select + category/docHref reference (Fluent CRM):
  `wp-content/plugins/fluent-subscriptions-for-jetformbuilder-main/src/Actions/FluentCrmSubscribeAction.php`
  `wp-content/plugins/fluent-subscriptions-for-jetformbuilder-main/assets/js/editor-action.js`
- WP component catalog: https://developer.wordpress.org/block-editor/reference-guides/components/

Related Skills

jfb-action-messages

5
from nvdigitalsolutions/mcp-ai-wpoos

Surfaces user-facing custom messages from a JetFormBuilder custom Form Action — both the idiomatic path (register message types via 'jet-form-builder/form-messages/register' so they appear in the form's Messages panel and can be overridden globally per form) and the action-local path (custom message fields inside the action editor, dispatched via Action_Exception for errors or via context + 'jet-form-builder/form-handler/after-send' + Messages_Manager::dynamic_success() for success messages). Use when a custom JFB action needs configurable messages for cases like "already subscribed", "duplicate row skipped", "API rate limited", or per-action success copy. Triggers on mentions of "JFB messages", "Action_Exception", "Base_Action_Messages", "jet-form-builder/form-messages/register", "_jf_messages", "dynamic_success", "add_context_once" with a message, "after-send" hook, or "custom action message".

jfb-action-item-decorator

5
from nvdigitalsolutions/mcp-ai-wpoos

Wraps every action item in the JetFormBuilder action editor with custom UI via the 'jet.fb.action.item' wp.hooks filter — the wrapper renders on top of (or alongside) the original action editor for each action and can read/write that action's settings and events array. Use to add quick toggles or panels to every action without modifying each action's own editor — e.g. a TRUE/FALSE/Always button group that drives which custom event the action responds to, a "run once per session" toggle, an inline label override, any visual shortcut over the action's persisted state. Triggers on mentions of "jet.fb.action.item", "useLoopedAction", "useActionsEdit", "useActions", "ActionItemWrapper", "ActionItemBody", "decorate every action", "per-action toggle", or "visual control over action events".

jfb-action-external-api

5
from nvdigitalsolutions/mcp-ai-wpoos

How to read submitted JetFormBuilder form data from a custom action, transform it (including %macro% replacement of admin-typed templates), call an external HTTP API, write the response back into the form context for downstream actions to use, and dispatch JFB events based on the API outcome. Use when building a custom action that integrates with a third-party service (LLM call, webhook, CRM, payment processor, geolocation lookup) and needs the action to behave as a node in a data-flow graph rather than a one-shot leaf. Triggers on mentions of "jet_fb_context", "update_request", "has_field", "get_value", "wp_remote_post" with form data, "macro replacement", or "API call from action".

jfb-action-events

5
from nvdigitalsolutions/mcp-ai-wpoos

Configures the JetFormBuilder action events system — the named events (DEFAULT.PROCESS, DEFAULT.REQUIRED, GATEWAY.SUCCESS, GATEWAY.FAILED, BAD.REQUEST, ON.DYNAMIC_STATE, never) that decide WHEN a Form Action runs. Covers declaring supported / unsupported / required events on a custom action, the validation precedence inside Base_Event::is_valid_action(), the multi-event subscription model the action editor exposes, and how to register a brand-new event type via the 'jet-form-builder/event-types' filter using Base_Event, Base_Action_Event, or Base_Gateway_Event. Use when an action needs to run only on payment callbacks, only on validation failure, only on a specific conditional state, after another action's hidden flow, or when the plugin needs to define its own event class (e.g. for inbound webhooks). Triggers on mentions of "JFB events", "action events", "GATEWAY.SUCCESS", "DEFAULT.PROCESS", "supported_events", "unsupported_events", "get_required_events", "Base_Event", "Base_Gatewa...

wp-action-scheduler

5
from nvdigitalsolutions/mcp-ai-wpoos

Design and review Action Scheduler jobs in WordPress plugins using Action Scheduler 3.9.x public APIs - async, single, recurring, and cron-expression actions; action_scheduler_init load timing; hook/args/group naming; unique and priority parameters; idempotent callbacks; chunked workloads; activation/deactivation cleanup; WooCommerce-bundled or standalone dependency usage; admin and WP-CLI debugging; queue runner limits; and safe operational troubleshooting. Use when a plugin schedules background jobs with as_enqueue_async_action, as_schedule_single_action, as_schedule_recurring_action, as_schedule_cron_action, as_get_scheduled_actions, or integrates with WooCommerce background queues.

webapp-testing

5
from nvdigitalsolutions/mcp-ai-wpoos

Toolkit for interacting with and testing local web applications using Playwright. Supports verifying frontend functionality, debugging UI behavior, capturing browser screenshots, and viewing browser logs.

web-artifacts-builder

5
from nvdigitalsolutions/mcp-ai-wpoos

Suite of tools for creating elaborate, multi-component claude.ai HTML artifacts using modern frontend web technologies (React, Tailwind CSS, shadcn/ui). Use for complex artifacts requiring state management, routing, or shadcn/ui components - not for simple single-file HTML/JSX artifacts.

valyu

5
from nvdigitalsolutions/mcp-ai-wpoos

Search the live web and 36+ specialised data sources including SEC filings, PubMed, ChEMBL, clinical trials, FRED economic indicators, and patent databases. Use when current, authoritative, or paywalled data is required.

ui-ux-pro-max

5
from nvdigitalsolutions/mcp-ai-wpoos

AI-powered design intelligence with 67 UI styles, 161 color palettes, 57 font pairings, 99 UX guidelines, and 25 chart types across 15+ tech stacks. Generates complete design systems for any product type with industry-specific reasoning rules.

theme-factory

5
from nvdigitalsolutions/mcp-ai-wpoos

Toolkit for styling artifacts with a theme. These artifacts can be slides, docs, reportings, HTML landing pages, etc. There are 10 pre-set themes with colors/fonts that you can apply to any artifact that has been creating, or can generate a new theme on-the-fly.

slack-gif-creator

5
from nvdigitalsolutions/mcp-ai-wpoos

Knowledge and utilities for creating animated GIFs optimized for Slack. Provides constraints, validation tools, and animation concepts. Use when users request animated GIFs for Slack like "make me a GIF of X doing Y for Slack."

skill-creator

5
from nvdigitalsolutions/mcp-ai-wpoos

Create new skills, modify and improve existing skills, and measure skill performance. Use when users want to create a skill from scratch, update or optimize an existing skill, run evals to test a skill, benchmark skill performance with variance analysis, or optimize a skill's description for better triggering accuracy.