Lesson 4 built an ability that can correctly edit one Elementor element, but it flatly
refuses to run unless the target post carries an _ai_edit_of meta value. This lesson
builds the two abilities that make that possible: one that duplicates a post for an AI
agent to work on, and a separate one, gated behind a stronger permission check, that
copies an approved edit back onto the original. Nothing an AI agent does in this course
ever writes directly to a live, published page builder post.
Lesson 4’s update-elementor-element ability. Course 4’s permission_callback patterns, since the approval ability here needs a meaningfully stronger check than the editing ability does.
Step 1: Duplicate the post, and every relevant meta key
wp_insert_post() is the real, standard WordPress function for creating a post, and
duplicating an existing one is just a matter of copying its fields into a new
wp_insert_post() call, then copying its post meta across afterward:
// File: my-plugin.php
function my_plugin_duplicate_post_for_ai_edit( int $original_id ): int|WP_Error {
$original = get_post( $original_id );
if ( ! $original ) {
return new WP_Error( 'not_found', __( 'Original post not found.', 'my-plugin' ) );
}
$duplicate_id = wp_insert_post(
array(
'post_title' => $original->post_title . ' (AI edit draft)',
'post_content' => $original->post_content,
'post_excerpt' => $original->post_excerpt,
'post_type' => $original->post_type,
'post_status' => 'draft',
'post_author' => $original->post_author,
),
true
);
if ( is_wp_error( $duplicate_id ) ) {
return $duplicate_id;
}
// Copy every meta key, not just _elementor_data. Elementor and other builders
// also store related caches and settings under separate keys.
foreach ( get_post_meta( $original_id ) as $key => $values ) {
foreach ( $values as $value ) {
add_post_meta( $duplicate_id, $key, maybe_unserialize( $value ) );
}
}
update_post_meta( $duplicate_id, '_ai_edit_of', $original_id );
return $duplicate_id;
}
Looping over every key from get_post_meta( $original_id ), rather than hardcoding just
_elementor_data, matters here. Elementor also stores generated CSS references and page
settings under other meta keys, and the same is true for Divi, WPBakery, and Beaver
Builder in their own ways. Copying only the one key you know about produces a duplicate
that edits correctly but can render wrong in the builder’s own editor.
If the well-known Duplicate Post plugin is already active on the site, it already solves exactly this problem and is a reasonable alternative to writing your own copy logic. Confirm the exact function names it exposes for the version actually installed, third-party plugin APIs can change names or signatures across major releases in ways WordPress core functions don’t.
Step 2: Register the duplicate-creation ability
// File: my-plugin.php
add_action( 'wp_abilities_api_init', function () {
wp_register_ability(
'page-builder-ai/create-edit-duplicate',
array(
'label' => __( 'Create a duplicate for AI editing', 'my-plugin' ),
'description' => __(
'Creates a draft duplicate of a page builder post, tagged for AI editing. ' .
'Always call this before any element-editing ability, never edit an original.',
'my-plugin'
),
'category' => 'page-builder-ai',
'input_schema' => array(
'type' => 'object',
'properties' => array( 'post_id' => array( 'type' => 'integer' ) ),
'required' => array( 'post_id' ),
),
'output_schema' => array(
'type' => 'object',
'properties' => array(
'duplicate_id' => array( 'type' => 'integer' ),
'edit_url' => array( 'type' => 'string' ),
),
),
'permission_callback' => function ( $input ) {
return current_user_can( 'edit_post', $input['post_id'] );
},
'execute_callback' => function ( $input ) {
$duplicate_id = my_plugin_duplicate_post_for_ai_edit( $input['post_id'] );
if ( is_wp_error( $duplicate_id ) ) {
return $duplicate_id;
}
return array(
'duplicate_id' => $duplicate_id,
'edit_url' => get_edit_post_link( $duplicate_id, 'raw' ),
);
},
'meta' => array(
'annotations' => array( 'destructive' => false, 'idempotent' => false ),
'mcp' => array( 'public' => false ),
),
)
);
} );
Step 3: The approval ability, gated on a stronger capability
Copying the duplicate’s edited content back onto the original is the one genuinely destructive step in this whole workflow, and it’s the one that should never be reachable by the same low-privilege check used for editing a draft. Require a real, explicit human approval action here, backed by a stricter capability check:
// File: my-plugin.php
add_action( 'wp_abilities_api_init', function () {
wp_register_ability(
'page-builder-ai/apply-approved-edit',
array(
'label' => __( 'Apply an approved AI edit', 'my-plugin' ),
'description' => __(
'Copies an AI-edited duplicate\'s content and meta back onto the original ' .
'post. Only call this after a human has explicitly reviewed the duplicate.',
'my-plugin'
),
'category' => 'page-builder-ai',
'input_schema' => array(
'type' => 'object',
'properties' => array( 'duplicate_id' => array( 'type' => 'integer' ) ),
'required' => array( 'duplicate_id' ),
),
'permission_callback' => function ( $input ) {
$original_id = get_post_meta( $input['duplicate_id'], '_ai_edit_of', true );
if ( ! $original_id ) {
return new WP_Error( 'not_a_duplicate', __( 'This is not a tracked AI edit duplicate.', 'my-plugin' ) );
}
// A deliberately stronger check than editing a draft requires.
return current_user_can( 'publish_posts' ) && current_user_can( 'edit_post', $original_id );
},
'execute_callback' => function ( $input ) {
$original_id = (int) get_post_meta( $input['duplicate_id'], '_ai_edit_of', true );
$duplicate = get_post( $input['duplicate_id'] );
wp_update_post( array(
'ID' => $original_id,
'post_content' => $duplicate->post_content,
) );
foreach ( get_post_meta( $input['duplicate_id'] ) as $key => $values ) {
if ( '_ai_edit_of' === $key ) {
continue;
}
update_post_meta( $original_id, $key, maybe_unserialize( $values[0] ) );
}
wp_trash_post( $input['duplicate_id'] );
return array( 'applied' => true, 'original_id' => $original_id );
},
'meta' => array(
'annotations' => array( 'destructive' => true, 'idempotent' => false ),
'mcp' => array( 'public' => false ),
),
)
);
} );
Trashing the duplicate rather than deleting it outright leaves an audit trail: if a review turns out to be wrong after the fact, the trashed duplicate is still recoverable.
Step 4: The full workflow, end to end
Test it
# File: terminal
wp eval '
$dup = wp_get_ability( "page-builder-ai/create-edit-duplicate" )->execute( array( "post_id" => 45 ) );
var_dump( $dup );
'
Confirm the returned duplicate_id is a new draft post, that _ai_edit_of on it points
back to 45, and that post 45 itself is completely unchanged. Only after manually
reviewing the duplicate should you call apply-approved-edit with that duplicate’s id.
If a duplicate renders correctly in wp-admin but looks wrong (missing styles, broken
spacing) when opened in Elementor’s own editor, the most common cause is copying only
_elementor_data and skipping the builder’s other, related meta keys, cached page
assets or generated CSS references in particular. Loop over the full result of
get_post_meta( $original_id ) as shown above, not a hardcoded single key.
Recap
You built a real duplication ability using wp_insert_post() that copies a post’s
fields and all of its meta, tags the copy with _ai_edit_of so Lesson 4’s ability can
verify it’s safe to edit, and a separate, more strongly permissioned ability that only a
human-approved call can trigger to copy the reviewed edit back onto the original. No
ability in this pattern writes to a live, published post directly, every change passes
through a draft duplicate and an explicit approval step first.
Resources & further reading
- wp_insert_post() function reference, developer.wordpress.org
- Duplicate Post plugin, wordpress.org
- WordPress MCP Security & Authentication, Course 4