Editorial Automation

Project: Internal-Linking Automation

⏱ 15 min

This project builds an ability that suggests relevant internal links for a given post, based on semantic similarity to your other published content, using embeddings from the AI Client SDK. It returns a list of suggested target posts and anchor text, it never edits post_content to insert links itself.

What you'll learn in this lesson
How to use embeddings to find semantically related posts
The same AiClient::input()->generateEmbedding() and cosine similarity approach as the site chatbot project, applied to link suggestions instead of Q&A.
How to generate natural anchor text for a suggested link
Using the AI Client SDK to phrase a suggestion, not just list a URL.
Why link insertion stays manual
The editorial and structural risk of an AI silently rewriting post content to insert links.
How to exclude a post from linking to itself
And why this is an easy bug to miss.
Prerequisites

Course 5 for AiClient::input()->generateEmbedding(). This lesson is self-contained and doesn’t require having built the Site-Content Chatbot project, though the embedding technique is the same one used there.

Step 1: Register the ability

Input is a post ID. Output is a list of suggestions, each with a target post and suggested anchor text, never a modified body of content.

// File: internal-linker/internal-linker.php
add_action( 'wp_abilities_api_init', function () {
	wp_register_ability(
		'internal-linker/suggest-links',
		array(
			'label'               => __( 'Suggest internal links', 'internal-linker' ),
			'description'         => __( 'Suggests relevant internal links for a post based on semantic similarity to other published content. Returns suggestions only, never modifies post content.', 'internal-linker' ),
			'category'            => 'content',
			'input_schema'        => array(
				'type'       => 'object',
				'properties' => array( 'post_id' => array( 'type' => 'integer' ) ),
				'required'   => array( 'post_id' ),
			),
			'output_schema'       => array(
				'type'  => 'array',
				'items' => array(
					'type'       => 'object',
					'properties' => array(
						'target_post_id' => array( 'type' => 'integer' ),
						'target_title'   => array( 'type' => 'string' ),
						'anchor_text'    => array( 'type' => 'string' ),
						'similarity'     => array( 'type' => 'number' ),
					),
				),
			),
			'permission_callback' => function ( $input ) {
				return current_user_can( 'edit_post', $input['post_id'] );
			},
			'execute_callback'    => 'internal_linker_suggest',
			'meta' => array(
				'annotations' => array( 'readonly' => true ),
				'mcp'         => array( 'public' => true ),
			),
		)
	);
} );

The same embedding-and-cosine-similarity approach from the site chatbot project, applied here to compare one post against every other published post rather than against a user’s question.

// File: internal-linker/internal-linker.php
use WordPress\AiClient\AiClient;

function internal_linker_get_embedding( $post ) {
	$cached = get_post_meta( $post->ID, '_content_embedding', true );
	if ( $cached ) {
		$decoded = json_decode( $cached, true );
		if ( is_array( $decoded ) ) {
			return $decoded;
		}
	}

	$text   = $post->post_title . "\n\n" . wp_strip_all_tags( $post->post_content );
	$values = AiClient::input( $text )->generateEmbedding()->getValues();

	if ( ! empty( $values ) ) {
		update_post_meta( $post->ID, '_content_embedding', wp_json_encode( $values ) );
	}

	return $values;
}

function internal_linker_cosine_similarity( $a, $b ) {
	$dot = $mag_a = $mag_b = 0;
	$len = min( count( $a ), count( $b ) );
	for ( $i = 0; $i < $len; $i++ ) {
		$dot   += $a[ $i ] * $b[ $i ];
		$mag_a += $a[ $i ] ** 2;
		$mag_b += $b[ $i ] ** 2;
	}
	return ( $mag_a && $mag_b ) ? $dot / ( sqrt( $mag_a ) * sqrt( $mag_b ) ) : 0;
}

Caching each post’s embedding in post meta the first time it’s computed means running this ability repeatedly doesn’t re-embed the same content, and the same meta key doubles as reusable infrastructure if you also build the Site-Content Chatbot project.

Step 3: Rank candidates and phrase the anchor text

// File: internal-linker/internal-linker.php
function internal_linker_suggest( $input ) {
	$source = get_post( $input['post_id'] );
	if ( ! $source ) {
		return new WP_Error( 'not_found', 'Post not found.' );
	}

	$source_embedding = internal_linker_get_embedding( $source );
	if ( empty( $source_embedding ) ) {
		return new WP_Error( 'embedding_failed', 'Could not generate an embedding for this post.' );
	}

	$candidates = get_posts( array(
		'post_status'    => 'publish',
		'posts_per_page' => -1,
		'post__not_in'   => array( $source->ID ), // Never suggest linking a post to itself.
	) );

	$scored = array();
	foreach ( $candidates as $candidate ) {
		$embedding = internal_linker_get_embedding( $candidate );
		if ( empty( $embedding ) ) {
			continue;
		}
		$scored[] = array(
			'post'       => $candidate,
			'similarity' => internal_linker_cosine_similarity( $source_embedding, $embedding ),
		);
	}

	usort( $scored, fn( $a, $b ) => $b['similarity'] <=> $a['similarity'] );
	$top = array_slice( $scored, 0, 5 );

	$suggestions = array();
	foreach ( $top as $match ) {
		if ( $match['similarity'] < 0.7 ) {
			continue; // Not similar enough to be a genuinely useful link.
		}

		$anchor_prompt = "Suggest a short, natural anchor text (3-8 words) for a link from a post about \"{$source->post_title}\" to a post titled \"{$match['post']->post_title}\". Return only the anchor text.";
		$anchor_text   = trim( wp_ai_client_prompt( $anchor_prompt )->generate_text() );

		$suggestions[] = array(
			'target_post_id' => $match['post']->ID,
			'target_title'   => $match['post']->post_title,
			'anchor_text'    => $anchor_text ?: $match['post']->post_title,
			'similarity'     => round( $match['similarity'], 3 ),
		);
	}

	return $suggestions;
}

The post__not_in exclusion and the 0.7 similarity floor are both doing real work here: without the first, a post can be “suggested” as a link target for itself, without the second, low-relevance matches get suggested just because they happened to rank in the top five of a small content library.

Test it

Run the ability against a real post and inspect the suggestions:

wp-env run cli wp eval '
$result = wp_get_ability( "internal-linker/suggest-links" )->execute( array( "post_id" => 42 ) );
print_r( $result );
'

Confirm no entry’s target_post_id equals 42, that every similarity score is at or above 0.7, and that the suggested target_title values are genuinely topically related when you read them, not just numerically similar.

Not caching embeddings and re-embedding on every call

Without the get_post_meta() check at the top of internal_linker_get_embedding(), every single call to this ability would regenerate embeddings for every published post, an AI provider call per post, every time. On a site with even a few hundred posts, that’s slow and expensive. Cache aggressively, and consider a save_post hook (as shown in the Site-Content Chatbot lesson) to keep embeddings fresh as content changes instead of computing them lazily inside this ability.

Recap

This ability embeds the source post and every other published post, ranks candidates by cosine similarity, filters out anything below a relevance threshold, then asks wp_ai_client_prompt() to phrase natural anchor text for each surviving suggestion. Nothing is written into post_content, the output is a suggestion list a human editor reviews and inserts manually, keeping the actual editorial judgment about where links belong with the person publishing the content.

Resources & further reading

← Project: Content Moderation Tool Project: Multilingual Translation Workflow →