Building It

Building an Ability That Edits an Elementor Component Safely

⏱ 22 min

Now the real build. This lesson registers an ability that finds one specific element inside _elementor_data by its stable id, the field Lesson 2 covered, and updates only that element’s settings, leaving everything else in the tree untouched. It deliberately does not decide, on its own, whether it’s allowed to run against a live page, that’s Lessons 5 and 6’s job. This ability’s contract is narrower and more mechanical: given a post ID and an element ID, edit that one element correctly.

What you'll learn in this lesson
Finding an element by stable id, recursively
Walking the elType/elements tree without relying on array position.
A complete, registered ability
Input schema, permission callback, and an execute_callback that reads, edits, and re-saves _elementor_data.
Encoding JSON the way WordPress expects
wp_json_encode() and wp_slash(), and why skipping either one corrupts saved text.
Why this ability should never target a live post id
What "safely" means here, and what it doesn't cover yet.
Prerequisites

Lesson 2’s understanding of _elementor_data’s structure. A registered MCP server from Course 2, with a category and at least one ability already working end to end.

Step 1: A recursive finder, keyed by id

Elementor’s content array nests elements inside elements arbitrarily deep, and the depth differs by version (Lesson 2’s section/column versus container distinction). A finder function has to walk the whole tree and match on id, never on array index, since index has no stable meaning across saves or between the two structural styles:

// File: my-plugin.php
function my_plugin_find_elementor_element( array $elements, string $target_id ): ?array {
	foreach ( $elements as $index => $element ) {
		if ( isset( $element['id'] ) && $element['id'] === $target_id ) {
			return array( 'path' => array( $index ), 'element' => $element );
		}

		if ( ! empty( $element['elements'] ) ) {
			$found = my_plugin_find_elementor_element( $element['elements'], $target_id );
			if ( $found ) {
				array_unshift( $found['path'], $index );
				return $found;
			}
		}
	}

	return null;
}

This returns both the matched element and the path of array indices needed to reach it, so the calling code can write a modified copy back into the exact same spot in the tree.

Step 2: The ability itself

// File: my-plugin.php
add_action( 'wp_abilities_api_categories_init', function () {
	wp_register_ability_category(
		'page-builder-ai',
		array(
			'label'       => __( 'Page Builder AI', 'my-plugin' ),
			'description' => __( 'Abilities for structure-aware page builder editing.', 'my-plugin' ),
		)
	);
} );

add_action( 'wp_abilities_api_init', function () {
	wp_register_ability(
		'page-builder-ai/update-elementor-element',
		array(
			'label'               => __( 'Update Elementor element', 'my-plugin' ),
			'description'         => __(
				'Updates the settings of one specific Elementor element, found by its ' .
				'stable id, inside a post\'s _elementor_data. Only ever run this against ' .
				'a duplicate post id, never a live, published post.',
				'my-plugin'
			),
			'category'            => 'page-builder-ai',
			'input_schema'        => array(
				'type'       => 'object',
				'properties' => array(
					'post_id'    => array( 'type' => 'integer', 'description' => 'The duplicate post to edit.' ),
					'element_id' => array( 'type' => 'string', 'description' => 'The Elementor element\'s stable id.' ),
					'settings'   => array(
						'type'        => 'object',
						'description' => 'Key/value pairs to merge into the element\'s existing settings.',
					),
				),
				'required'   => array( 'post_id', 'element_id', 'settings' ),
			),
			'output_schema'       => array(
				'type'       => 'object',
				'properties' => array(
					'updated'    => array( 'type' => 'boolean' ),
					'element_id' => array( 'type' => 'string' ),
				),
			),
			'permission_callback' => function ( $input ) {
				if ( ! current_user_can( 'edit_post', $input['post_id'] ) ) {
					return false;
				}
				if ( ! get_post_meta( $input['post_id'], '_ai_edit_of', true ) ) {
					return new WP_Error(
						'not_a_duplicate',
						__( 'This ability only runs against a duplicate created for AI editing, not an original post.', 'my-plugin' )
					);
				}
				return true;
			},
			'execute_callback'    => function ( $input ) {
				$raw = get_post_meta( $input['post_id'], '_elementor_data', true );
				if ( ! $raw ) {
					return new WP_Error( 'no_elementor_data', __( 'This post has no Elementor data.', 'my-plugin' ) );
				}

				$content = json_decode( $raw, true );
				if ( ! is_array( $content ) ) {
					return new WP_Error( 'invalid_json', __( 'Could not decode _elementor_data.', 'my-plugin' ) );
				}

				$found = my_plugin_find_elementor_element( $content, $input['element_id'] );
				if ( ! $found ) {
					return new WP_Error( 'element_not_found', __( 'No element with that id was found.', 'my-plugin' ) );
				}

				$path = $found['path'];
				$ref  = &$content;
				foreach ( $path as $i => $index ) {
					if ( $i === count( $path ) - 1 ) {
						$ref[ $index ]['settings'] = array_merge(
							$ref[ $index ]['settings'] ?? array(),
							$input['settings']
						);
					} else {
						$ref = &$ref[ $index ]['elements'];
					}
				}
				unset( $ref );

				update_post_meta(
					$input['post_id'],
					'_elementor_data',
					wp_slash( wp_json_encode( $content ) )
				);

				return array( 'updated' => true, 'element_id' => $input['element_id'] );
			},
			'meta'                => array(
				'annotations' => array( 'destructive' => true, 'idempotent' => true ),
				'mcp'         => array( 'public' => false ),
			),
		)
	);
} );

Step 3: Why the permission callback checks for _ai_edit_of

Notice the permission callback doesn’t just check current_user_can( 'edit_post', ... ), it also requires a _ai_edit_of meta value on the target post. Lesson 5 sets that meta key when it creates a duplicate specifically for AI editing. Requiring it here means this ability structurally cannot run against an arbitrary live post, even if a caller somehow got hold of a real post ID, because a genuine, published original post will never carry that flag.

This ability alone is not the safety pattern

Everything in this lesson operates correctly and safely on the JSON, but it has no opinion about whether the post it’s editing should be edited by an AI agent at all beyond the _ai_edit_of check. That check only works because Lesson 5 is the piece that creates duplicates and marks them, and Lesson 6 is the piece that shuts this ability off entirely outside a safe environment. Don’t ship this ability without both.

Test it

Register the ability, then call it directly against a test post that already has the _ai_edit_of meta key set (Lesson 5 shows how that gets set for real):

# File: terminal
wp eval '
update_post_meta( 123, "_ai_edit_of", 45 ); // simulate a duplicate, for this test only
$result = wp_get_ability( "page-builder-ai/update-elementor-element" )->execute( array(
	"post_id"    => 123,
	"element_id" => "8b4e027",
	"settings"   => array( "title" => "A new headline from an AI agent" ),
) );
var_dump( $result );
'

Then re-fetch _elementor_data for post 123 and confirm the settings.title for that element id actually changed, and that every other element in the tree is untouched.

Forgetting wp_slash() before update_post_meta()

If saved text loses backslashes, for example an apostrophe rendered as a broken character or a JSON string that fails to decode next time you read it, the cause is almost always skipping wp_slash() on the encoded JSON before calling update_post_meta(). WordPress’s meta-saving path unslashes incoming data as if it had magic quotes applied, and wp_slash() is what compensates for that on the way in.

Recap

You built a real ability that walks Elementor’s recursive element tree, finds one element by its stable id, merges new values into just that element’s settings, and re-saves _elementor_data using wp_json_encode() and wp_slash() so the JSON survives the round trip intact. The permission callback’s _ai_edit_of check ties this ability directly to the duplicate-and-approve workflow the next lesson builds, this ability is never meant to run standalone against a live post.

Resources & further reading

← Understanding Divi's Data Structure The Duplicate-and-Approve Workflow →