AgentStack
Browse Sign in
Browse Why AgentStack Sell Docs
Sign in
SKILL verified MIT Self-run

Fluentcrm Funnel Action

skill-lonsdale201-wp-agent-skills-fluentcrm-funnel-action · by Lonsdale201

Build a custom FluentCRM funnel action — a sequence step that

No reviews yet
0 installs
38 views
0.0% view→install

Install

$ agentstack add skill-lonsdale201-wp-agent-skills-fluentcrm-funnel-action

✓ scanned · ✓ verified, works with Claude Code, Cursor, and more.

Security review

✓ Passed

No issues found. Passed automated security review. · v0.1.0 How review works →

  • Prompt-injection patterns
  • Secret / credential exfiltration
  • Dangerous shell & filesystem operations
  • Untrusted network calls
  • Known-malicious package signatures

What it can access

  • Network access No
  • Filesystem access No
  • Shell / process execution No
  • Environment & secrets No
  • Dynamic code execution No

From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.

View the full security report →

Verified badge

Passed review? Show it. Paste this badge into your README, it links to the public security report.

AgentStack Verified badge Links to your public security report.
[![AgentStack Verified](https://agentstack.voostack.com/badges/verified.svg)](https://agentstack.voostack.com/security/report/skill-lonsdale201-wp-agent-skills-fluentcrm-funnel-action)

Reliability & compatibility

Security review passed
0 installs to date
no reviews yet
2mo ago

Declared compatibility

Claude CodeClaude Desktop

Compatibility is declared by the source manifest. End-to-end runtime verification is coming, see below.

Preview Execution monitoring

We're building live execution health for every listing: tool-call success rate, median latency, uptime, and last-checked timestamps, measured, not self-reported. It isn't live yet, so we don't show numbers we can't stand behind.

How agent discovery & health will work →
Are you the author of Fluentcrm Funnel Action? Claim this listing to set pricing, connect Stripe payouts, and keep 70% of every sale.
Sign up to claim

About

FluentCRM: register a custom funnel action

For developers building a companion plugin that needs to add a step inside FluentCRM's automation funnel — "Enroll user to LMS course", "Issue Woo coupon", "Send to webhook", "Update post status". Each action is a block the admin drags into the funnel sequence; when a contact reaches the block, the handler runs once per (contact, sequence) tuple. Extends FluentCrm\App\Services\Funnel\BaseAction. Verified end-to-end against FluentCRM 2.9.87 source.

API stability note

BaseAction, the fluentcrm_funnel_blocks / fluentcrm_funnel_block_fields filter pair, the per-action fluentcrm_funnel_sequence_handle_{name} action, and the (subscriber, sequence, funnelSubscriberId, funnelMetric) handler signature have been stable since 2.7. The implicit-complete pattern (FunnelProcessor sets sequence status 'complete' before dispatching the handler — see Misconception #2 below) has been in place since 1.2 of the funnel processor.

Misconception this skill corrects

> "I'll mark the sequence as 'completed' from my handler when the work succeeds."

Two bugs in one sentence.

Bug A: the canonical string is 'complete', not 'completed'. FluentCRM core writes 'complete' everywhere — FunnelProcessor::processSequence at [FunnelProcessor.php:214](FunnelProcessor.php), the default in FunnelHelper::changeFunnelSubSequenceStatus() at [FunnelHelper.php:16](FunnelHelper.php), and every built-in action in app/Services/Funnel/Actions/*.php. Writing 'completed' to the database does not break execution but the admin "complete" funnel-progress filter never matches your rows; the sequence appears unfinished forever in reports.

Bug B: you don't need to mark anything as complete on success. FunnelProcessor::processSequence() calls FunnelHelper::changeFunnelSubSequenceStatus($funnelSubscriberId, $sequence->id, 'complete') at [FunnelProcessor.php:214](FunnelProcessor.php) — before firing do_action('fluentcrm_funnel_sequence_handle_' . $sequence->action_name, ...) at [FunnelProcessor.php:223](FunnelProcessor.php). By the time your handler runs, the sequence is already marked 'complete'. Your job is to override only when you want a different outcome:

  • Returning normally → status stays 'complete' (set by the processor)
  • Early-return because a precondition failed → call changeFunnelSubSequenceStatus(..., 'skipped') AND set $funnelMetric->status = 'skipped'
  • API call failed → same pattern with 'skipped' (or 'failed' — see "Status vocabulary" below)

Other AI-prone misconceptions:

  • "I'll register my action on fluent_crm/after_init." Same timing bug as triggers. BaseAction::register() adds itself to two filters (fluentcrm_funnel_blocks, fluentcrm_funnel_block_fields) plus one action listener (fluentcrm_funnel_sequence_handle_{name}). The block / field filters drive the editor UI — load too late and the action picker shows your block but the editor renders an empty settings panel. The action listener powers runtime — load too late and reaching your block at runtime does nothing. Register on fluentcrm_loaded priority below 10, identical rule to triggers. See fluentcrm-funnel-trigger for the lifecycle diagram.
  • "I need to record success metrics in $funnelMetric myself." No — the metric row was already created by FunnelProcessor::recordFunnelMetric() at [FunnelProcessor.php:213](FunnelProcessor.php) immediately before dispatching the handler. Your handler receives the live model. Write $funnelMetric->notes = '...' (visible in the admin's automation log row) and $funnelMetric->status = '...' (only on skip/failure overrides), then $funnelMetric->save(). Don't new FunnelMetric() yourself.
  • "$subscriber->user_id is the WP user ID." Sometimes. The Subscriber model column user_id is the LINKED WP user, but it's null for guests. Use $subscriber->getWpUserId() which encapsulates the lookup (FluentCampaign Pro built-ins always go through this — [AddToCourseAction.php:76](AddToCourseAction.php) is the canonical example).
  • "The action runs every time the contact reaches the block." Almost. processSequence checks $funnelMetric->wasRecentlyCreated at [FunnelProcessor.php:222](FunnelProcessor.php) — the handler fires only once per (contact, sequence) pair. If the same contact is re-enrolled in the funnel, they get a fresh metric row and the handler fires again. If the funnel re-uses the sequence (loop), the existing metric short-circuits the dispatch. Don't write idempotency logic in your handler unless you also handle re-enrollment yourself.
  • "getBlockFields() and getBlock() carry the same shape as triggers." Different terminology, different filters. Triggers use getTrigger() + getSettingsFields() + getConditionFields() and feed fluentcrm_funnel_triggers. Actions use getBlock() + getBlockFields() and feed fluentcrm_funnel_blocks + fluentcrm_funnel_block_fields. Block payload shape uses 'category' + 'title' (NOT 'label'); BaseAction stamps 'type' => 'action' itself at [BaseAction.php:30](BaseAction.php).
  • "Block-level defaults belong in getBlockFields." The 'settings' key on the getBlock() return is what seeds new instances of the block in the editor. getBlockFields() shapes the editor form (labels, types, dependencies). Get the split wrong and you end up with editor fields that have no default value, or settings the editor doesn't know how to render.
  • "getBlock() without 'settings' is fine — the editor reads defaults from getBlockFields()." It is not fine, and this is the most painful failure mode in this contract. If getBlock() doesn't return a 'settings' hash, the editor renders the action panel with an undefined settings object. The Vue components bind directly to settings. — the FIRST field's setter call throws TypeError: Cannot read properties of undefined (reading '') in start.js, the editor catches the throw and the panel renders empty. Any new action you ship MUST seed 'settings' with one entry per field in getBlockFields()['fields']. The keys MUST match exactly. The values are the per-field defaults the admin sees on first drop.

When to use this skill

Trigger when ANY of the following is true:

  • Building an integration action that runs per-contact when a funnel reaches a sequence step (LMS enroll, coupon issue, CPT update, webhook fire, file generate).
  • The diff/files reference BaseAction, fluentcrm_funnel_blocks, fluentcrm_funnel_block_fields, fluentcrm_funnel_sequence_handle_*, changeFunnelSubSequenceStatus, $funnelMetric.
  • Reviewing code that registers actions on fluent_crm/after_init or init.
  • Debugging "my action runs but the sequence is stuck on processing" — almost always a status-string typo ('completed' vs 'complete') or a missing ->save() on $funnelMetric.
  • Debugging "the action panel renders empty when I drag the block in" or a console error like TypeError: Cannot read properties of undefined (reading 'product_id') in start.js / boot.js — almost always a missing 'settings' seed in getBlock(). See Misconception #6.

Step 1 — Register on the right hook (same rule as triggers)

actionName = 'my_plugin_do_thing';
        $this->priority   = 20;
        parent::__construct();
    }

    public function getBlock()
    {
        return [
            'category'    => __('My Service', 'my-plugin'),
            'title'       => __('Do The Thing', 'my-plugin'),
            'description' => __('Calls My Service for the contact.', 'my-plugin'),
            'icon'        => 'fc-icon-trigger',
            // CRITICAL — 'settings' is the seed for new block instances. The
            // editor's Vue components bind directly to settings.;
            // omit this and dragging the block in throws
            // `TypeError: Cannot read properties of undefined (reading
            // '')` in start.js, leaving the panel empty.
            // Keys MUST match getBlockFields()['fields'] keys exactly.
            'settings'    => [
                'thing_id'        => '',
                'send_welcome'    => 'yes',
                'skip_for_public' => 'no',
            ],
        ];
    }

    public function getBlockFields()
    {
        return [
            'title'     => __('Do The Thing', 'my-plugin'),
            'sub_title' => __('Calls My Service for the contact.', 'my-plugin'),
            'fields'    => [
                'thing_id' => [
                    'type'        => 'rest_selector',
                    'option_key'  => 'my_plugin_things',  // pairs with fluentcrm_ajax_options_my_plugin_things filter
                    'is_multiple' => false,
                    'clearable'   => true,
                    'label'       => __('Select Thing', 'my-plugin'),
                    'placeholder' => __('Select Thing', 'my-plugin'),
                ],
                'skip_for_public' => [
                    'type'        => 'yes_no_check',
                    'check_label' => __('Skip if contact has no WP user account.', 'my-plugin'),
                ],
                'send_welcome' => [
                    'type'        => 'yes_no_check',
                    'check_label' => __('Send default WP welcome email if a new user is created.', 'my-plugin'),
                    'dependency'  => [
                        'depends_on' => 'skip_for_public',
                        'operator'   => '=',
                        'value'      => 'no',
                    ],
                ],
            ],
        ];
    }

    public function handle($subscriber, $sequence, $funnelSubscriberId, $funnelMetric)
    {
        $settings = $sequence->settings;
        $thingId  = (int) Arr::get($settings, 'thing_id');
        $userId   = $subscriber->getWpUserId();

        // SKIP path #1 — config invalid.
        if ($thingId notes  = __('Skipped: no thing selected.', 'my-plugin');
            $funnelMetric->status = 'skipped';
            $funnelMetric->save();
            FunnelHelper::changeFunnelSubSequenceStatus($funnelSubscriberId, $sequence->id, 'skipped');
            return false;
        }

        // SKIP path #2 — guest contact + admin asked to skip guests.
        if (!$userId && Arr::get($settings, 'skip_for_public') === 'yes') {
            $funnelMetric->notes  = __('Skipped: contact is not a WP user.', 'my-plugin');
            $funnelMetric->status = 'skipped';
            $funnelMetric->save();
            FunnelHelper::changeFunnelSubSequenceStatus($funnelSubscriberId, $sequence->id, 'skipped');
            return false;
        }

        // Real work goes here.
        $result = my_service_do_thing_for_user($userId, $thingId);

        if (is_wp_error($result)) {
            $funnelMetric->notes  = $result->get_error_message();
            $funnelMetric->status = 'skipped';
            $funnelMetric->save();
            FunnelHelper::changeFunnelSubSequenceStatus($funnelSubscriberId, $sequence->id, 'skipped');
            return false;
        }

        // SUCCESS path — DO NOT call changeFunnelSubSequenceStatus(..., 'complete').
        // FunnelProcessor::processSequence already wrote 'complete' at line 214
        // before dispatching us. Just leave a useful note and return.
        $funnelMetric->notes = __('Thing done successfully.', 'my-plugin');
        $funnelMetric->save();

        return true;
    }
}

Step 3 — Status vocabulary

The two records that track action state and the strings each accepts:

| Record | Method to write | Canonical values | |--|--|--| | Sequence Subscriber (FunnelSubscriber join) | FunnelHelper::changeFunnelSubSequenceStatus($funnelSubscriberId, $sequenceId, $status) | 'pending', 'complete', 'skipped' | | Funnel metric row (per-step audit log) | $funnelMetric->status = '...'; $funnelMetric->save(); | 'pending', 'complete', 'skipped', 'failed' |

Notes:

  • 'complete' not 'completed'. Double-check every string literal against FunnelProcessor::processSequence() if unsure.
  • The metric row supports 'failed' — useful for logging unrecoverable errors distinctly from configuration skips. The sequence-subscriber record only has 'skipped'. Map your retry semantics accordingly: 'failed' on the metric tells the admin "this needs investigation"; 'skipped' on the join tells the funnel "move past this step".
  • 'pending' is the initial state, set by recordFunnelMetric and changeFunnelSubSequenceStatus defaults. Don't write it from your handler.
  • Save the metric. $funnelMetric->save() is what persists notes + status to disk; without it the admin's automation log shows blanks.

Step 4 — How the registration plumbs through

Follow the chain from the editor click to your handler:

  1. Admin opens the funnel editor → editor REST request → server filters via fluentcrm_funnel_blocks (your pushBlock adds your block to the picker, [BaseAction.php:24-35](BaseAction.php)) and fluentcrm_funnel_block_fields (your pushBlockFields adds your editor form schema, [BaseAction.php:37-43](BaseAction.php)).
  2. Admin drags your block into the sequence + saves the funnel → the sequence row stores action_name = 'my_plugin_do_thing' plus the settings JSON.
  3. At runtime, when a contact reaches your sequence step, FunnelProcessor::processSequence() runs:
  • recordFunnelMetric creates the metric row (status 'pending').
  • changeFunnelSubSequenceStatus(..., 'complete') flips the sequence-subscriber row to 'complete'.
  • do_action('fluentcrm_funnel_sequence_handle_' . $sequence->action_name, $subscriber, $sequence, $funnelSubscriberId, $funnelMetric) ([FunnelProcessor.php:223](FunnelProcessor.php)) — your handle() runs.
  1. Your handler does the work and overrides status only on skip/failure.

Critical rules

  • **Register on fluentcrm_loaded priority save() is required after writing notes / status`.
  • $subscriber->getWpUserId() not $subscriber->user_id. The latter is null for guests; the former is the canonical lookup.
  • getBlock()['settings'] keys MUST equal getBlockFields()['fields'] keys. The settings hash seeds new block instances; the fields hash drives the editor form. Misalignment = settings keys the editor can't render or fields with no default.
  • Plugin-presence detection MUST use a file-load-time symbol — a top-level class declared in the dependency's main file (class_exists('TopLevelClass')) or a constant define()'d at file scope (defined('CONST_NAME')). NEVER function_exists('helper') — those helpers are typically declared inside the dependency's own plugins_loaded callback. Two plugins on plugins_loaded:10 run in registration order (non-deterministic), so a function-based check passes when the dep loaded first and silently fails when it loaded second — your action then disappears from the editor's block picker on half the requests. Canonical example: WC Memberships → class_exists('WC_Memberships_Loader') (file scope, race-free) NOT function_exists('wc_memberships') (declared inside init_plugin() callback).

Common mistakes

  • Skipping the 'settings' seed in getBlock(). Editor renders the action panel empty and the JS console throws TypeError: Cannot read properties of undefined (reading ''). The fix is one block — copy every key from your getBlockFields()['fields'] array into getBlock()['settings'] with sensible defaults ('' for text/select/rest_selector, 'no' / 'yes' for radio toggles, 0 or 1 for numeric, [] for multi-select arrays).
  • Calling do_action('fluentcrm_funnel_sequence_handle_*') from your own code. That action is fired by FunnelProcessor::processSequence only. Calling it directly bypasses the metric record, the wasRecentlyCreated check (so it can fire infinitely), and the implicit 'complete' status mark.
  • **Using `'completed'

Source & license

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

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

Reviews

No reviews yet, be the first.

Versions

  • v0.1.0 Imported from the upstream source.