# Je Listings Callback

> Register a custom Listings callback for JetEngine — the

- **Type:** Skill
- **Install:** `agentstack add skill-lonsdale201-wp-agent-skills-je-listings-callback`
- **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/jet-engine/je-listings-callback

## Install

```sh
agentstack add skill-lonsdale201-wp-agent-skills-je-listings-callback
```

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

## About

# JetEngine: register a Listings callback (Dynamic Field filter)

For developers extending JetEngine's Dynamic Field widget with custom value transforms — "Format date", "Format number", "Add URL scheme", "Convert units", "Pretty link", and so on. JetEngine ships ~30 built-ins; this skill is for the registration contract that lets your plugin add a new entry to the "Callback to filter field value" dropdown plus its companion controls.

## Misconception this skill corrects

> "I'll register a callback by giving JE a string name and a class+method. JE will figure out how to call it."

JE will NOT figure it out. The callback identifier you pass in `'filter_callback' => [ 'unit_converter' ]` and via `register_callback( 'unit_converter', ... )` is the SAME STRING that JE later passes to `call_user_func_array()`. PHP's `is_callable()` is the gate at [callbacks.php:535](callbacks.php) — and `is_callable( 'unit_converter' )` is `false` unless there is a global function with that name. A static method `MyPlugin\Callbacks\UnitConverter::unit_converter()` does NOT satisfy `is_callable( 'unit_converter' )`.

When the gate fails, `apply_callback()` returns `null` early ([callbacks.php:531-537](callbacks.php)) and the dynamic-field render assigns that `null` back to the value. The field renders as **empty** — no error, no warning, just a blank where the converted value should be. Easy to misdiagnose as "the callback ran and produced empty output" when in fact the gate rejected the callback and it never ran at all.

JE's own callbacks satisfy the gate two ways:

- **PHP built-ins** — `'number_format'`, `'wpautop'`, `'do_shortcode'`, `'human_time_diff'`, `'wp_oembed_get'`, `'make_clickable'`, `'wp_get_attachment_image'`, `'get_the_title'`, `'get_permalink'`, `'get_term_link'`, `'date_i18n'`, `'zeroise'`. These are PHP/WP global functions, so `is_callable()` returns true.
- **JE-prefixed globals** — `'jet_engine_date'`, `'jet_engine_proportional'`, `'jet_engine_get_user_data_by_id'`, `'jet_engine_url_scheme'`, `'jet_engine_render_multiselect'`, `'jet_engine_render_checklist'`, etc. These are declared as global functions in [includes/core/functions.php](functions.php) — NOT static methods. That is why they pass the callable gate.

Your callback MUST follow the same pattern: declare a global function (or use a fully-qualified static-method string `Vendor\\My\\Callbacks\\Convert::run`).

## When to use this skill

Trigger this skill when ANY of the following is true:

- The user asks "how do I add a callback to the Dynamic Field widget", "Format custom values", "extend the JE field filter dropdown".
- The diff or file contains: `add_filter( 'jet-engine/listings/allowed-callbacks'`, `add_filter( 'jet-engine/listings/allowed-callbacks-args'`, `add_filter( 'jet-engine/listing/dynamic-field/callback-args'`, `add_action( 'jet-engine/callbacks/register'`, or a class named `*Callback*`/`*Converter*` registering with JE.
- A listing field set to "Filter field output" with a custom callback renders empty when it shouldn't — likely the callable-gate trap.

## Workflow

### 1. Pick a registration path

JE supports two surfaces. They produce the same result; pick by ergonomics.

| Path | When to use | Hooks |
|---|---|---|
| Legacy 3-filter | Adding multiple callbacks at once, or matching the JE built-in style | `jet-engine/listings/allowed-callbacks` (label map), `jet-engine/listings/allowed-callbacks-args` (control schema), `jet-engine/listing/dynamic-field/callback-args` (positional args) |
| Modern `register_callback()` | Adding one callback with self-contained args; less boilerplate | Action `jet-engine/callbacks/register` → `$manager->register_callback( $name, $label, $args )` which fires the 3 filters internally |

The modern path is verified at [callbacks.php:35](callbacks.php) — the action fires inside the manager constructor. The `register_callback()` method at [callbacks.php:51](callbacks.php) stores the callback definition; `register_callbacks()` at line 66, `register_callbacks_args()` at line 81, and `apply_callbacks_args()` at line 117 then satisfy the same three filters that the legacy path uses directly.

### 2. Bootstrap

Both paths need `function_exists( 'jet_engine' )` because the manager is what fires the action. Hook on `plugins_loaded` priority 11 (after JE's bootstrap):

```php
add_action( 'plugins_loaded', static function (): void {
    if ( ! class_exists( '\Jet_Engine' ) ) {
        return;
    }

    // MODERN path
    add_action( 'jet-engine/callbacks/register', [ \MyPlugin\Callbacks\Manager::class, 'register' ] );

    // LEGACY path (alternative)
    add_filter( 'jet-engine/listings/allowed-callbacks',          [ \MyPlugin\Callbacks\Manager::class, 'add_callback_label' ] );
    add_filter( 'jet-engine/listings/allowed-callbacks-args',     [ \MyPlugin\Callbacks\Manager::class, 'add_callback_controls' ] );
    add_filter( 'jet-engine/listing/dynamic-field/callback-args', [ \MyPlugin\Callbacks\Manager::class, 'apply_callback_args' ], 10, 4 );
}, 11 );
```

### 3. Declare the callback function as a GLOBAL function

This is the step everyone gets wrong. The callback identifier MUST be a PHP-callable string. The two acceptable shapes:

```php
// Option A — global function. Readable, easy.
function myplugin_format_thousands_short( $value, int $decimals = 1, string $suffix_separator = '' ): string {
    // implementation
}

// Option B — fully-qualified static method as a string. Verbose but namespaceable.
namespace MyPlugin\Callbacks;
class FormatThousandsShort {
    public static function run( $value, int $decimals = 1, string $suffix_separator = '' ): string {
        // implementation
    }
}
// Register as the FQN string:
$manager->register_callback(
    'MyPlugin\\Callbacks\\FormatThousandsShort::run',
    __( 'Format thousands (1.5K)', 'myplugin' ),
    [ /* args */ ]
);
```

Option A is what the JE built-ins use — see [functions.php:1226 (`jet_engine_proportional`)](functions.php), [line 1268 (`jet_engine_date`)](functions.php), [line 1279 (`jet_engine_get_user_data_by_id`)](functions.php). Mirror that style: prefix with your plugin slug (`mvp_`, `myplugin_`, `je_skills_smoke_`) to avoid clashes. Auto-loaded via the same loader as your classes.

### 4. Modern registration — `register_callback()`

```php
namespace MyPlugin\Callbacks;

class Manager {

    public static function register( $manager ): void {
        $manager->register_callback(
            'myplugin_format_thousands_short',
            __( 'Format thousands (1.5K)', 'myplugin' ),
            [
                'thousands_decimals' => [
                    'label'   => __( 'Decimal points', 'myplugin' ),
                    'type'    => 'number',
                    'min'     => 0,
                    'max'     => 3,
                    'step'    => 1,
                    'default' => 1,
                ],
                'thousands_suffix_separator' => [
                    'label'   => __( 'Separator before suffix', 'myplugin' ),
                    'type'    => 'text',
                    'default' => '',
                ],
            ]
        );
    }
}
```

The `args` array entries become per-callback controls. JE adds the `condition` (`dynamic_field_filter` + `filter_callback`) entries automatically so the controls only show when this callback is selected ([callbacks.php:81-110](callbacks.php)). The control `type` accepts `text` / `number` / `select` / `switcher`, and `select` takes `options`.

### 5. Legacy registration — three filters by hand

```php
namespace MyPlugin\Callbacks;

class Manager {

    public static function add_callback_label( array $callbacks ): array {
        $callbacks['myplugin_format_thousands_short'] = __( 'Format thousands (1.5K)', 'myplugin' );
        return $callbacks;
    }

    public static function add_callback_controls( array $args ): array {
        $args['thousands_decimals'] = [
            'label'   => __( 'Decimal points', 'myplugin' ),
            'type'    => 'number',
            'min'     => 0,
            'max'     => 3,
            'step'    => 1,
            'default' => 1,
            'condition' => [
                'dynamic_field_filter' => 'yes',
                'filter_callback'      => [ 'myplugin_format_thousands_short' ],
            ],
        ];
        $args['thousands_suffix_separator'] = [
            'label'   => __( 'Separator before suffix', 'myplugin' ),
            'type'    => 'text',
            'default' => '',
            'condition' => [
                'dynamic_field_filter' => 'yes',
                'filter_callback'      => [ 'myplugin_format_thousands_short' ],
            ],
        ];
        return $args;
    }

    public static function apply_callback_args( array $args, string $callback, array $settings = [], $widget = null ): array {
        if ( 'myplugin_format_thousands_short' === $callback ) {
            $args[] = $settings['thousands_decimals']         ?? 1;
            $args[] = $settings['thousands_suffix_separator'] ?? '';
        }
        return $args;
    }
}
```

The legacy path requires you to write the `condition` entries yourself; modern hides that. Otherwise identical.

### 6. Argument-flow: how settings reach your function

At runtime when the field renders:

1. Dynamic Field widget calls `Jet_Engine_Listings_Callbacks::apply_callback( $input, $callback, $settings, $widget )` ([manager.php:535](manager.php)).
2. `apply_callback()` runs `is_callable($callback) && is_allowed_callback($callback)` — the gate ([callbacks.php:535](callbacks.php)).
3. For built-in callbacks the `switch` populates positional `$args` directly. For your callback the `default` case fires `apply_filters( 'jet-engine/listing/dynamic-field/callback-args', [ $result ], $callback, $settings, $widget )` ([callbacks.php:725-731](callbacks.php)) — the third filter or `apply_callbacks_args()` from `register_callback()`. Each entry of your `$args` definition is appended in declaration order to the array, with the saved value or the declared default.
4. `call_user_func_array( $callback, $args )` ([callbacks.php:736](callbacks.php)) — the result becomes the rendered field value.

So your function signature MUST match: first parameter is the field value, then your declared args in the same order:

```php
function myplugin_format_thousands_short(
    $value,                     // = 1e9 ) { $short = $n / 1e9; $suffix = 'B'; }
    elseif ( $abs >= 1e6 ) { $short = $n / 1e6; $suffix = 'M'; }
    elseif ( $abs >= 1e3 ) { $short = $n / 1e3; $suffix = 'K'; }
    else { return number_format( $n, 0 ); }
    return number_format( $short, $decimals ) . $suffix_separator . $suffix;
}
```

Mismatched arg order is a silent-rendering bug — your function gets the wrong values but doesn't error.

## Critical rules

- **The callback identifier MUST satisfy `is_callable()`.** A bare static method name like `'unit_converter'` does not — only a global function name (`'myplugin_unit_converter'`) or a fully-qualified static-method string (`'MyPlugin\\Callbacks\\UnitConverter::run'`) does. The gate at [callbacks.php:535](callbacks.php) returns `null` on failure → field renders empty with no error.
- **Both paths produce the same result.** The modern `register_callback()` fires the legacy 3 filters internally — pick by ergonomics, not capability.
- **Namespace-prefix your callback identifier** (`myplugin_*` or fully-qualified) to avoid clashing with another plugin's same-named callback.
- **Function signature: `$value` first, then your args in declaration order.** JE's `apply_callbacks_args()` appends settings in the order you declared them ([callbacks.php:117-138](callbacks.php)).
- **Args declarations need a `condition`.** With the legacy path you write `[ 'dynamic_field_filter' => 'yes', 'filter_callback' => [ 'your-callback' ] ]` yourself. With the modern path JE adds it for you. Without `condition`, your controls show under EVERY callback selection.
- **Each arg key is unique across the JE editor.** The same args dictionary feeds every callback's UI. Conflicts (e.g. multiple plugins declaring `decimal_point`) silently overwrite. Prefix arg keys: `myplugin_decimal_point` not `decimal_point`.
- **Hook on `plugins_loaded:11`** so JE's listing manager is loaded. The action `jet-engine/callbacks/register` fires from the `Jet_Engine_Listings_Callbacks` constructor, called from `Jet_Engine_Listings::__construct`.
- **Return a SCALAR (string/number) from your function.** The dynamic-field render at [render/dynamic-field.php:334-340](dynamic-field.php) accepts a value or a `WP_Error` (which becomes a "callback applying" warning). Returning arrays/objects converts to "Array" / class-name strings.

## Common mistakes

```php
// WRONG — bare static method name. Fails is_callable() and the field renders empty.
class Convert {
    public static function units( $value, $from, $to ) { /* ... */ }
}
add_filter( 'jet-engine/listings/allowed-callbacks', function( $cb ) {
    $cb['convert_units'] = 'Convert units';   // 🔴 'convert_units' is not callable
    return $cb;
} );
// Even if every condition lines up, JE's apply_callback() returns null. Field is empty.

// RIGHT — global function. The simplest fix.
function myplugin_convert_units( $value, $from = 'cm', $to = 'cm' ) { /* ... */ }
add_filter( 'jet-engine/listings/allowed-callbacks', function( $cb ) {
    $cb['myplugin_convert_units'] = 'Convert units';
    return $cb;
} );

// RIGHT — fully-qualified static method as a string. Less common but valid.
$manager->register_callback(
    'MyPlugin\\Callbacks\\Convert::units',
    __( 'Convert units', 'myplugin' ),
    [ /* args */ ]
);

// WRONG — args declared without condition. Controls appear under every callback.
$args['my_decimal'] = [
    'label'   => 'Decimal',
    'type'    => 'number',
    'default' => 2,
];   // 🔴 No condition → leaks into every callback's panel

// RIGHT — scope to your callback only.
$args['my_decimal'] = [
    'label'   => 'Decimal',
    'type'    => 'number',
    'default' => 2,
    'condition' => [
        'dynamic_field_filter' => 'yes',
        'filter_callback'      => [ 'myplugin_format_thousands_short' ],
    ],
];

// WRONG — function signature in the wrong order.
function myplugin_format_thousands_short( int $decimals, $value ) { /* ... */ }
// 🔴 JE passes [ $value, $decimals ]. Your function receives $value as $decimals.
// Output is silently wrong — no error.

// RIGHT — $value first, then args in declaration order.
function myplugin_format_thousands_short( $value, int $decimals = 1, string $sep = '' ) { /* ... */ }

// WRONG — arg key collision with another plugin or a JE built-in.
$args['decimal_point'] = [ /* ... */ ];   // 🔴 conflicts with JE's number_format args

// RIGHT — prefix every arg key.
$args['myplugin_thousands_decimal_point'] = [ /* ... */ ];

// WRONG — returning a non-scalar from the callback.
function myplugin_get_meta_array( $value ) {
    return [ 'a', 'b', 'c' ];   // 🔴 dynamic-field renders as "Array"
}

// RIGHT — return a string. Use a delimiter helper or implode for arrays.
function myplugin_get_meta_array( $value, string $delim = ', ' ) {
    return implode( $delim, (array) $value );
}
```

## Cross-references

- Run **`je-query-builder-custom-type`** when the customization is "I need a new query type" rather than "I need a per-field transform". Different extension surface, complementary.
- Run **`je-dynamic-visibility-condition`** when the customization is "show / hide this widget based on context" rather than "transform a field value".
- Run **`wp-plugin-architecture`** for the companion-plugin scaffold and the `plugins_loaded:11` priority pattern.

## What this skill does NOT cover

- **Bricks-views and Gutenberg integration.** The same callback registration drives Bricks Dynamic Field and JE's block-editor dynamic content; you don't register a separate callback per renderer. UI specifics for Bricks/Blocks are out of scope.
- **Ma

…

## 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-je-listings-callback
- 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%.
