Skip to chapter content

Chapter 26 5 min read

Smart Comment Reply Suggestions

Logged-in-only frontend feature, JS + REST

This chapter adds one button to WordPress’s own comment form: “Suggest a Reply.” Click it while replying to someone’s comment, and a draft reply appears in the textarea, ready to edit and submit, or replace entirely.

A different kind of permission check

function ai_course_ch26_register_rest_route() {
    register_rest_route(
        'ai-course/v1',
        '/suggest-reply',
        array(
            'methods'             => 'POST',
            'callback'            => 'ai_course_ch26_suggest_reply',
            // Any logged-in user, not a specific capability like
            // edit_posts, this just proves it's a real account.
            'permission_callback' => 'is_user_logged_in',
            'args'                => array(
                'comment_id' => array(
                    'required'          => true,
                    'type'              => 'integer',
                    'sanitize_callback' => 'absint',
                ),
            ),
        )
    );
}
add_action( 'rest_api_init', 'ai_course_ch26_register_rest_route' );

Every earlier chapter that checked login status also checked a specific capability, like edit_posts or upload_files, something tied to what the feature actually does. This one doesn’t need that. Anyone with an account can leave a comment, so anyone with an account can use this. is_user_logged_in() on its own is the right check here.

Suggesting a reply to a specific comment

function ai_course_ch26_suggest_reply( WP_REST_Request $request ) {
    $comment_id = $request->get_param( 'comment_id' );
    $comment    = get_comment( $comment_id );

    // Exists isn't enough, only ever use a comment that's actually
    // approved and publicly visible, this ID came straight from the
    // client, not necessarily from a comment someone could actually see.
    if ( ! $comment || '1' !== $comment->comment_approved ) {
        return new WP_Error( 'ai_course_comment_not_found', 'That comment could not be found.' );
    }

    $original_text = wp_strip_all_tags( $comment->comment_content );

    // Explicitly asking for one plain reply, nothing else, keeps the
    // model from returning several styled options with markdown formatting.
    $result = wp_ai_client_prompt( "Write one short, friendly, relevant reply to this comment. Return only the reply text itself, no options, no formatting, no markdown, nothing else:\n\n{$original_text}" )
        ->using_temperature( 0.7 )
        ->generate_text_result();

    return rest_ensure_response( $result );
}

This function takes a comment ID as a parameter, not the actual text. So it looks up the real comment with get_comment() first. That matters even though comment content is already public, it makes sure the AI only ever sees a comment that genuinely exists on your site.

But get_comment() alone doesn’t check whether that comment is something a visitor could actually see. The ID could belong to something still pending moderation, marked as spam, or already trashed, none of which a real visitor would ever see on the page. The '1' !== $comment->comment_approved check rules those out, so only an approved, publicly visible comment ever reaches the AI.

Without an explicit instruction, a model often returns several reply options, each wrapped in markdown formatting like **Thanks for the kind words!**. That reads fine as markdown, but a plain textarea doesn’t render it, the asterisks would just show up as literal characters around the text, not bold.

The prompt asks for exactly one plain reply instead, though like any instruction, that’s not a hard guarantee. If a reply ever comes back with asterisks still in it, that just means the model didn’t follow the instruction perfectly, not a bug in the code.

Finding out which comment you’re replying to

WordPress already tracks which comment you’re replying to, using a hidden field called comment_parent. If you clicked “Reply” on a comment, that field holds the comment’s ID. If you’re writing a fresh, top-level comment instead, it’s 0.

// WordPress's own comment form already tracks which comment you're
// replying to in this hidden field, it's "0" if you're not replying
// to anything yet.
const parentField = document.getElementById( 'comment_parent' );
const commentId = parentField ? parentField.value : '0';

if ( ! commentId || commentId === '0' ) {
    alert( 'Click "Reply" on a comment first, then try again.' );
    return;
}

The first two lines grab that field and read its value. The if right after stops everything else from running when there’s no comment to reply to, showing a reminder instead of silently doing nothing.

Two more functions, both familiar patterns

Two functions in the full file below haven’t been shown yet.

ai_course_ch26_enqueue_assets() loads the JavaScript file and sets up the nonce, the exact same pattern Chapter 25 used, just gated behind is_user_logged_in() instead of running for everyone.

ai_course_ch26_add_button() is worth a closer look:

function ai_course_ch26_add_button( $submit_field ) {
    if ( ! is_user_logged_in() || ! comments_open() ) {
        return $submit_field;
    }

    $button = '<button type="button" id="ai-course-ch26-button">Suggest a Reply</button>';

    return str_replace( '</p>', $button . '</p>', $submit_field );
}
add_filter( 'comment_form_submit_field', 'ai_course_ch26_add_button' );

By default, comment_form_submit_field wraps the submit button in <p class="form-submit">...</p>. Adding the button after that whole string would drop it below the form, since paragraphs are their own block. Adding it just before the closing </p> instead puts it inside that same wrapper, right next to the submit button.

This only works with that default </p> format, though. A theme that changes the markup would mean str_replace() finds nothing to match, and the button won’t show up.

Putting it together

Create chapter-26-suggest-reply.php inside includes:

<?php
/**
* Chapter 26: Smart Comment Reply Suggestions
* Usage: log in, open a post with comments, click "Reply" on an existing
* comment, then click "Suggest a Reply" near the comment form.
*/
if ( ! defined( 'ABSPATH' ) ) {
exit; // No direct access.
}
// Registers the REST endpoint the button's JavaScript will call.
function ai_course_ch26_register_rest_route() {
register_rest_route(
'ai-course/v1',
'/suggest-reply',
array(
'methods' => 'POST',
'callback' => 'ai_course_ch26_suggest_reply',
// Any logged-in user, not a specific capability like
// edit_posts, this just proves it's a real account.
'permission_callback' => 'is_user_logged_in',
'args' => array(
'comment_id' => array(
'required' => true,
'type' => 'integer',
'sanitize_callback' => 'absint',
),
),
)
);
}
add_action( 'rest_api_init', 'ai_course_ch26_register_rest_route' );
// Looks up the comment being replied to, and asks the AI for a
// suggested reply to it.
function ai_course_ch26_suggest_reply( WP_REST_Request $request ) {
$comment_id = $request->get_param( 'comment_id' );
$comment = get_comment( $comment_id );
// Exists isn't enough, only ever use a comment that's actually
// approved and publicly visible, this ID came straight from the
// client, not necessarily from a comment someone could actually see.
if ( ! $comment || '1' !== $comment->comment_approved ) {
return new WP_Error( 'ai_course_comment_not_found', 'That comment could not be found.' );
}
$original_text = wp_strip_all_tags( $comment->comment_content );
// Explicitly asking for one plain reply, nothing else, keeps the
// model from returning several styled options with markdown formatting.
$result = wp_ai_client_prompt( "Write one short, friendly, relevant reply to this comment. Return only the reply text itself, no options, no formatting, no markdown, nothing else:\n\n{$original_text}" )
->using_temperature( 0.7 )
->generate_text_result();
return rest_ensure_response( $result );
}
// Loads the button's JavaScript, only for logged-in visitors.
function ai_course_ch26_enqueue_assets() {
if ( ! is_user_logged_in() ) {
return;
}
wp_enqueue_script(
'ai-course-ch26-suggest-reply',
plugins_url( 'js/chapter-26-suggest-reply.js', __FILE__ ),
array( 'wp-api-fetch' ),
'1.0',
true
);
wp_add_inline_script(
'ai-course-ch26-suggest-reply',
sprintf(
'wp.apiFetch.use( wp.apiFetch.createNonceMiddleware( %s ) );',
wp_json_encode( wp_create_nonce( 'wp_rest' ) )
),
'before'
);
}
add_action( 'wp_enqueue_scripts', 'ai_course_ch26_enqueue_assets' );
// Adds the button inside WordPress's own submit-button wrapper, right
// before its closing tag, so it sits inline next to Post Comment instead
// of dropping to its own line below it.
function ai_course_ch26_add_button( $submit_field ) {
if ( ! is_user_logged_in() || ! comments_open() ) {
return $submit_field;
}
$button = '<button type="button" id="ai-course-ch26-button">Suggest a Reply</button>';
return str_replace( '</p>', $button . '</p>', $submit_field );
}
add_filter( 'comment_form_submit_field', 'ai_course_ch26_add_button' );

Then create chapter-26-suggest-reply.js inside includes/js:

// If scripts load after the page has already finished loading (common
// with some caching/optimization plugins), DOMContentLoaded has already
// fired and never will again, so check readyState first.
function ai_course_ch26_init() {
const button = document.getElementById( 'ai-course-ch26-button' );
if ( ! button ) {
return;
}
button.addEventListener( 'click', () => {
// WordPress's own comment form already tracks which comment
// you're replying to in this hidden field, it's "0" if you're
// not replying to anything yet.
const parentField = document.getElementById( 'comment_parent' );
const commentId = parentField ? parentField.value : '0';
if ( ! commentId || commentId === '0' ) {
alert( 'Click "Reply" on a comment first, then try again.' );
return;
}
const textarea = document.getElementById( 'comment' );
if ( ! textarea ) {
return;
}
button.disabled = true;
button.textContent = 'Generating...';
wp.apiFetch( {
path: '/ai-course/v1/suggest-reply',
method: 'POST',
data: { comment_id: commentId }
} )
.then( ( data ) => {
let suggestion = '';
if ( data.candidates && data.candidates[ 0 ] && data.candidates[ 0 ].message ) {
data.candidates[ 0 ].message.parts.forEach( ( part ) => {
if ( part.text ) {
suggestion += part.text;
}
} );
}
// Drop the suggestion straight into the reply textarea,
// the visitor can still edit it before submitting.
textarea.value = suggestion;
button.disabled = false;
button.textContent = 'Suggest a Reply';
} )
.catch( () => {
button.disabled = false;
button.textContent = 'Suggest a Reply';
} );
} );
}
if ( 'loading' === document.readyState ) {
document.addEventListener( 'DOMContentLoaded', ai_course_ch26_init );
} else {
ai_course_ch26_init();
}

Log in, open a post with existing comments, and click “Reply” on one of them. A “Suggest a Reply” button appears right next to the Post Comment button. Click it, and the reply draft appears in the textarea itself, ready to send as-is or edit first.

While the request is in flight, the button disables itself and shows “Generating…”, then returns to normal once a response comes back, whether it succeeded or failed.

The actual reply text comes from data.candidates[0].message.parts, and goes straight into the textarea’s value, replacing whatever was there.

The JS also checks document.readyState before falling back to DOMContentLoaded. Some sites load scripts after the page has already finished loading, and this check makes sure the click handler still gets attached even then.

One thing worth noticing: if the request fails, the button just resets without saying why. Adding a real error message, and a rate limit to actually trigger one, are both left as an exercise below.

Try it yourself

Right now, the AI only sees the comment text, not what post it’s actually replying to. A suggested reply reads the same whether the comment is on a recipe or a product review. Add the post’s title to the prompt, get_the_title( $comment->comment_post_ID ) gets it, so the suggestion can reference what the comment is actually about, not just react to the words in isolation.

There’s also no rate limiting here. Any logged-in user, even a low-privilege one, could call this endpoint as many times as they want, generating AI costs each time. Add a transient-based limiter to this endpoint’s permission_callback, keyed to the current user ID instead of an IP address, since every caller here is already logged in.

While you’re at it, update the .catch() block to actually show that error, the same way Chapter 25’s version does, so a rate-limited visitor sees why the button didn’t work instead of it just quietly resetting.

This book is created with Chapterwright