Safety Pattern

The Duplicate-and-Approve Workflow

⏱ 20 min

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.

What you'll learn in this lesson
Duplicating a post with wp_insert_post()
Copying the original's fields and all of its builder-related post meta, not just one key.
Marking a duplicate for AI editing
The _ai_edit_of meta key Lesson 4's permission callback already checks for.
A separate, higher-privilege approval ability
The one and only path that copies a duplicate's content back onto the original.
Where the real Duplicate Post plugin fits in
An existing, well-known alternative if it's already active on the site.
Prerequisites

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.

An existing plugin can do this step for you

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

The complete duplicate-and-approve flow
1
Agent calls create-edit-duplicate
A new draft post is created, tagged with _ai_edit_of pointing at the original.
2
Agent calls update-elementor-element (or another builder's equivalent)
Only against the duplicate's post id, as Lesson 4's permission check enforces.
3
A human reviews the duplicate
Opening its edit link or preview and comparing it against the live original before approving anything.
4
A human-authorized call to apply-approved-edit
The only path that ever writes back onto the original, gated on a stronger capability.

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.

Copying only _elementor_data and missing related keys

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

← Building an Ability That Edits an Elementor Component Safely Production Guardrails: Auto-Deactivating on Live URLs →