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.
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.
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.
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
- Elementor Developers: Data Structure, developers.elementor.com
- wp_json_encode() function reference, developer.wordpress.org
- wp_register_ability() function reference, developer.wordpress.org