# Fluentcrm Funnel Trigger

> Build a custom FluentCRM funnel trigger by extending

- **Type:** Skill
- **Install:** `agentstack add skill-lonsdale201-wp-agent-skills-fluentcrm-funnel-trigger`
- **Verified:** Yes — security-reviewed for prompt injection and unsafe behavior
- **Seller:** [Lonsdale201](https://agentstack.voostack.com/s/lonsdale201)
- **Installs:** 0
- **Category:** [Agent Skills](https://agentstack.voostack.com/c/agent-skills)
- **Latest version:** 0.1.0
- **License:** MIT
- **Upstream author:** [Lonsdale201](https://github.com/Lonsdale201)
- **Source:** https://github.com/Lonsdale201/wp-agent-skills/tree/main/fluentcrm/fluentcrm-funnel-trigger

## Install

```sh
agentstack add skill-lonsdale201-wp-agent-skills-fluentcrm-funnel-trigger
```

Requires the [AgentStack CLI](https://agentstack.voostack.com/docs/cli). Works with Claude Code, Cursor, and any MCP-compatible agent.

## About

# FluentCRM: register a custom funnel trigger

For developers building a companion plugin that needs to start a FluentCRM automation when something happens (course enrolled, SaaS webhook arrived, custom CPT published, third-party event). Extends `FluentCrm\App\Services\Funnel\BaseTrigger` and registers via the `fluentcrm_funnel_triggers` filter chain. This skill maps the contract verified end-to-end against FluentCRM 2.9.87 source, with the lifecycle order written out in full because **timing is where this contract silently breaks**.

## API stability note

The `BaseTrigger` abstract class, the `fluentcrm_funnel_triggers` / `fluentcrm_funnel_start_*` / `fluentcrm_funnel_editor_details_*` / `fluentcrm_funnel_arg_num_*` hook family, and the `FunnelProcessor::startFunnelSequence()` entry point have been stable across the 2.7+ line. The `fluentcrm_funnel_settings` option (the registry of trigger names FluentCRM listens on) and `FunnelHandler::resetFunnelIndexes()` are internal implementation details that have not changed shape since the FunnelHandler was introduced.

## Misconception this skill corrects

> "I'll register my trigger on `fluent_crm/after_init` like the other addons do — it's a clean post-boot hook."

Wrong hook. **`fluent_crm/after_init` runs on `init` priority 1000** ([boot/app.php:44-46](boot/app.php)). FluentCRM's own `FunnelHandler::handle` runs on `fluentcrm_addons_loaded` ([actions.php:76](actions.php)), which fires **immediately after** `fluentcrm_loaded` ([boot/app.php:41-42](boot/app.php)) — i.e. before any `init` hook. By the time `fluent_crm/after_init` fires, FunnelHandler has already read the trigger registry option and added action listeners to your trigger names, and it has already evaluated `apply_filters('fluentcrm_funnel_arg_num_{name}', 1)` ([FunnelHandler.php:75](FunnelHandler.php)) — **with your filter not yet registered**, so `$argNum` falls back to `1` and only the first argument from your hook ever reaches `handle()`.

The visible bug: a trigger like `lw_lms_after_grant` (which fires with 5 args) gets only `$user_id` delivered. `$courseId = (int) ($originalArgs[1] ?? 0)` becomes `0`. Any guard `if ($courseId startFunnelSequence($funnel, $subscriberData, [...])`** at the end of your `handle()` method.
- **"`triggerName` is a label."** It is the **literal WP action hook name**. FluentCRM's FunnelHandler does `add_action($triggerName, ...)` ([FunnelHandler.php:76](FunnelHandler.php)). Either set `triggerName` to a real WP action that already fires (e.g. `tutor_after_enrolled`), or pick a custom name and fire `do_action('my_custom_event', $arg1, $arg2)` yourself from a bridge — see "Custom-name pattern" below.
- **"FluentCRM listens to my hook the moment my plugin loads."** Wrong. FluentCRM only adds a listener for trigger names present in the `fluentcrm_funnel_settings` WP option ([FunnelHandler.php:59-79](FunnelHandler.php)). That option is rebuilt by `resetFunnelIndexes()` ([FunnelHandler.php:144-170](FunnelHandler.php)), which runs when a funnel is created / updated / status-changed via `FunnelController`. **No published funnel using your trigger name → no listener → your trigger silently doesn't fire.** When you ship a new trigger, the user must publish (or re-save) at least one funnel using it before listeners exist.
- **"Setting `actionArgNum` is enough."** It is necessary but not sufficient. The integer is fed into a filter (`fluentcrm_funnel_arg_num_{name}`) registered by `BaseTrigger::register()`. The filter has to be in place **before** FunnelHandler reads it — see Misconception #1.
- **"I need to add the `__force_run_actions` toggle myself."** No — `BaseTrigger::prepareEditorDetails()` injects it automatically ([BaseTrigger.php:53-69](BaseTrigger.php)). Just declare your trigger-specific fields; the "Run actions even if contact is unsubscribed" toggle appears at the bottom of the settings panel for free.
- **"`isProcessable` is just business-logic filtering."** It also enforces the standard "If contact already in this funnel, skip / restart" semantics that every built-in trigger implements. Skip the `FunnelHelper::ifAlreadyInFunnel` guard and your trigger creates duplicate `FunnelSubscriber` rows on every event. See "Canonical isProcessable" below.

## When to use this skill

Trigger when ANY of the following is true:

- Building a companion plugin that needs to start a FluentCRM automation in response to a third-party event (LMS enrollment, SaaS webhook, CPT publish, payment processed, custom WP action).
- The diff/files reference `BaseTrigger`, `fluentcrm_funnel_triggers`, `fluentcrm_funnel_start_*`, `FunnelProcessor::startFunnelSequence`, `FunnelHelper::prepareUserData`, `FunnelHelper::ifAlreadyInFunnel`.
- Debugging "my trigger appears in the picker but the automation never runs" — almost always a timing issue (Misconception #1) or a missing-publish issue (Misconception #3).
- Reviewing code that hooks `fluent_crm/after_init` to register triggers.

## Lifecycle in one diagram

```
plugins_loaded:10            ← your plugin instantiates its bootstrap
        ↓
fluentcrm_loaded             ← do_action('fluentcrm_loaded') from boot/app.php:41
                               REGISTER TRIGGERS HERE (priority triggerName  = 'my_plugin_thing_happened';

        // priority for the fluentcrm_funnel_triggers filter (controls picker order)
        $this->priority     = 12;

        // Argument count this hook fires with. Verified against the do_action
        // call site — see Step 4 if you control the dispatch.
        $this->actionArgNum = 3;

        parent::__construct();
    }

    public function getTrigger()
    {
        return [
            'category'    => __('My Service', 'my-plugin'),
            'label'       => __('Thing Happened', 'my-plugin'),
            'description' => __('Fires when "the thing" happens in My Service.', 'my-plugin'),
            'icon'        => 'fc-icon-trigger',
        ];
    }

    public function getFunnelSettingsDefaults()
    {
        return [
            'subscription_status' => 'subscribed',
        ];
    }

    public function getSettingsFields($funnel)
    {
        return [
            'title'     => __('Thing Happened (My Service)', 'my-plugin'),
            'sub_title' => __('Starts when My Service emits the event.',  'my-plugin'),
            'fields'    => [
                'subscription_status' => [
                    'type'        => 'option_selectors',
                    'option_key'  => 'editable_statuses',
                    'is_multiple' => false,
                    'label'       => __('Subscription Status', 'my-plugin'),
                    'placeholder' => __('Select Status', 'my-plugin'),
                ],
                'subscription_status_info' => [
                    'type'       => 'html',
                    'info'       => '' . __('Pending status sends double-optin email.', 'my-plugin') . '',
                    'dependency' => [
                        'depends_on' => 'subscription_status',
                        'operator'   => '=',
                        'value'      => 'pending',
                    ],
                ],
            ],
        ];
    }

    public function getFunnelConditionDefaults($funnel)
    {
        return [
            'update_type'  => 'update', // skip_all_actions | skip_update_if_exist | update
            'thing_ids'    => [],
            'run_multiple' => 'no',
        ];
    }

    public function getConditionFields($funnel)
    {
        return [
            'update_type' => [
                'type'    => 'radio',
                'label'   => __('If Contact Already Exists?', 'my-plugin'),
                'help'    => __('What happens when the contact is already in the database.', 'my-plugin'),
                'options' => FunnelHelper::getUpdateOptions(),
            ],
            'thing_ids' => [
                'type'        => 'rest_selector',
                'option_key'  => 'my_plugin_things',  // see fluentcrm-rest-options skill
                'is_multiple' => true,
                'label'       => __('Target Things', 'my-plugin'),
                'inline_help' => __('Leave blank to run on every event.', 'my-plugin'),
            ],
            'run_multiple' => [
                'type'        => 'yes_no_check',
                'label'       => '',
                'check_label' => __('Restart automation multiple times for the same contact.', 'my-plugin'),
                'inline_help' => __('Without this, contacts already in the funnel are skipped.', 'my-plugin'),
            ],
        ];
    }

    public function handle($funnel, $originalArgs)
    {
        $userId  = (int) ($originalArgs[0] ?? 0);
        $thingId = (int) ($originalArgs[1] ?? 0);

        if ($userId isProcessable($funnel, $thingId, $subscriberData);

        // Required parity with FluentCampaign Pro triggers — third-party
        // plugins use this filter to add their own gating without forking.
        $willProcess = apply_filters(
            'fluentcrm_funnel_will_process_' . $this->triggerName,
            $willProcess, $funnel, $subscriberData, $originalArgs
        );

        if (!$willProcess) {
            return;
        }

        // Merge funnel-level settings (subscription_status etc.) into subscriberData
        // and translate to the wire format FunnelProcessor expects.
        $subscriberData = wp_parse_args($subscriberData, $funnel->settings);
        $subscriberData['status'] = !empty($subscriberData['subscription_status'])
            ? $subscriberData['subscription_status']
            : 'subscribed';
        unset($subscriberData['subscription_status']);

        (new FunnelProcessor())->startFunnelSequence($funnel, $subscriberData, [
            'source_trigger_name' => $this->triggerName,
            'source_ref_id'       => $thingId,
        ]);
    }

    private function isProcessable($funnel, int $thingId, array $subscriberData): bool
    {
        $conditions = (array) $funnel->conditions;

        if (Arr::get($conditions, 'update_type') === 'skip_all_if_exist'
            && FunnelHelper::getSubscriber($subscriberData['email'])
        ) {
            return false;
        }

        $thingIds = Arr::get($conditions, 'thing_ids', []);
        if (!empty($thingIds) && !in_array($thingId, array_map('intval', (array) $thingIds), true)) {
            return false;
        }

        $subscriber = FunnelHelper::getSubscriber($subscriberData['email']);
        if ($subscriber && FunnelHelper::ifAlreadyInFunnel($funnel->id, $subscriber->id)) {
            $multipleRun = Arr::get($conditions, 'run_multiple') === 'yes';
            if ($multipleRun) {
                FunnelHelper::removeSubscribersFromFunnel($funnel->id, [$subscriber->id]);
            } else {
                return false;
            }
        }

        return true;
    }
}
```

## Step 3 — When to use a real WP action vs a custom one

Two valid shapes:

**Real WP action (preferred when one exists).** Set `$triggerName` to the action name a third-party plugin / WP core / your own plugin already fires. FluentCRM picks it up automatically:

```php
$this->triggerName  = 'tutor_after_enrolled';   // real Tutor LMS hook
$this->actionArgNum = 2;                        // matches do_action('tutor_after_enrolled', $courseId, $userId)
```

No bridging needed.

**Custom action with a bridge (for synthesised / filtered events).** When the underlying event needs filtering before the trigger runs (e.g. "only fire on order created, not on every status change"), pick a unique custom name and fire it yourself:

```php
// In a separate "dispatcher" class loaded on fluentcrm_loaded too:
add_action('woocommerce_new_order', function ($orderId, $order) {
    // Skip if no published funnel uses this trigger — saves DB lookups.
    $hasActiveFunnel = \FluentCrm\App\Models\Funnel::where('status', 'published')
        ->where('trigger_name', 'my_custom_woo_status_changed')
        ->exists();
    if (!$hasActiveFunnel) {
        return;
    }

    do_action('my_custom_woo_status_changed', $orderId, $order);
}, 22, 2);
```

The trigger class then listens on `my_custom_woo_status_changed` with `actionArgNum = 2`. Same lifecycle rules apply — register on `fluentcrm_loaded` priority 5.

## Step 4 — How FluentCRM connects the dots

Follow the chain:

1. Your `getTrigger()` return value is filtered into `fluentcrm_funnel_triggers` ([BaseTrigger.php:21](BaseTrigger.php)) — that's how the picker UI sees your trigger.
2. When the admin saves a funnel using your trigger name, `FunnelController` calls `FunnelHandler::resetFunnelIndexes()` which writes your trigger name into the `fluentcrm_funnel_settings` option ([FunnelHandler.php:144-170](FunnelHandler.php)).
3. On the next request, `FunnelHandler::handle()` reads that option and adds the listener: `add_action($triggerName, ..., 10, $argNum)` ([FunnelHandler.php:75-79](FunnelHandler.php)) where `$argNum = apply_filters('fluentcrm_funnel_arg_num_'.$triggerName, 1)`.
4. When `do_action($triggerName, ...args)` fires, the listener calls `mapTriggers()` which dispatches `do_action("fluentcrm_funnel_start_{$triggerName}", $funnel, $originalArgs)` ([FunnelHandler.php:120](FunnelHandler.php)).
5. That action is what `BaseTrigger::register()` listens on at `fluentcrm_funnel_start_{$triggerName}` ([BaseTrigger.php:25](BaseTrigger.php)) → invokes your `handle($funnel, $originalArgs)`.

Two important consequences:

- The trigger only fires for **published** funnels (`Funnel::where('status', 'published')` — [FunnelHandler.php:109](FunnelHandler.php)). Drafts do nothing.
- The listener is registered ONCE per request. Adding triggers later in the lifecycle (e.g. on `init`) means your filter wasn't there when `$argNum` was captured — see the timing diagram.

## Critical rules

- **Register on `fluentcrm_loaded` priority settings)`) is what wires it through.
- **Not gating the dispatcher with `Funnel::where('status', 'published')`.** Without the gate, your dispatcher does work on every event even when no automation listens. Cheap to check, common-sense optimisation.
- **Storing trigger-specific state in `$funnel->settings` vs `$funnel->conditions`.** `settings` = funnel-level config that maps onto subscriber data (status, source). `conditions` = per-event gating (target IDs, run_multiple). Get the split wrong and the editor renders fields in the wrong panel.
- **Using `function_exists()` for dep detection at registration time.** The classic load-order race — the helper function is declared inside the dep's own `plugins_loaded` callback, which may or may not have run by the time your `fluentcrm_loaded:5` listener fires. Symptom: the trigger appears in the picker on some installs / page loads and not others. Fix: switch to a file-scope `class_exists` or `defined` check (see Critical rules).

## Cross-references

- Run **`fluentcrm-funnel-action`** when you need to add a custom block in the funnel sequence (an action that fires per step, not per event).
- Run **`fluentcrm-rest-options`** when your trigger or action uses `'type' => 'rest_selector'` for option pickers — you'll need to register the corresponding `fluentcrm_ajax_options_{key}` filter.
- See **`fluentcrm-overview`** (when written) for the full lifecycle / Free vs Pro / file map context.

## What this skill does NOT cover

- Building custom funnel **actions** (BaseAction) — see `fluentcrm-funnel-action`.
- Building custom **benchmarks** (BaseBenchMark, the "wait for X" branching nodes) — separate contract, separate registration filter.
- Custom **smart codes** / template tags (`{{my_code.foo}}`).
- The `fluent_crm/contact_*` lifecycle hooks (subscriber state changes outside the funnel system).
- Free vs Pro feature splits — the trigger contract is identical in both, only the editor UI varies.
- Block-editor email templates / styling — out of scope for the funnel layer.

## References

- [FluentCRM developer docs

…

## Source & license

This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.

- **Author:** [Lonsdale201](https://github.com/Lonsdale201)
- **Source:** [Lonsdale201/wp-agent-skills](https://github.com/Lonsdale201/wp-agent-skills)
- **License:** MIT

Install and usage instructions live in the source repository linked above.

## Pricing

- **Free** — Free

## Security capabilities

Automated source analysis of v0.1.0 — what this tool can access:

- **Network access:** no
- **Filesystem access:** no
- **Shell / process execution:** no
- **Environment & secrets:** no
- **Dynamic code execution:** no

*"Yes" means the capability is present in the source — more access means more to trust, not that it is unsafe.*


## Versions

- **0.1.0** — security scan: passed — Imported from the upstream source.

## Links

- Listing page: https://agentstack.voostack.com/l/skill-lonsdale201-wp-agent-skills-fluentcrm-funnel-trigger
- Seller: https://agentstack.voostack.com/s/lonsdale201
- Browse the marketplace: https://agentstack.voostack.com/browse

---
Listed on AgentStack — the marketplace for AI agent skills and MCP servers. Every listing is security-reviewed. Creators keep 70%.
