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.
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 ),
),
)
);
} );
Step 2: Find semantically related posts
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.
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
- PHP AI Client SDK repository, GitHub
- get_posts() function reference, developer.wordpress.org
- get_post_meta() function reference, developer.wordpress.org