Chapter 25
A Public AI-Powered FAQ / Search Widget
Public-facing REST endpoint security: nonces, rate limiting, capability scoping for logged-out users
This chapter builds a small FAQ widget: a text box and a button that answers a visitor’s question using real content from your site, not the model’s general knowledge. It works for anyone, logged in or not.
That’s a real shift from every earlier chapter. Every REST endpoint so far in this book has assumed someone logged in was calling it, current_user_can( 'edit_posts' ) or similar was always the gate. That gate doesn’t exist here, a logged-out visitor has no capability to check. Something else has to take its place.
No user to check, so check something else
function ai_course_ch25_register_rest_route() {
register_rest_route(
'ai-course/v1',
'/faq',
array(
'methods' => 'POST',
'callback' => 'ai_course_ch25_answer_question',
// No current_user_can() here, on purpose, see the rate
// limiter below for what actually gates this endpoint.
'permission_callback' => 'ai_course_ch25_check_rate_limit',
'args' => array(
'question' => array(
'required' => true,
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
),
),
)
);
}
add_action( 'rest_api_init', 'ai_course_ch25_register_rest_route' );
A logged-out visitor has no capability to check. So permission_callback points somewhere different this time: a function that decides based on how many requests are already coming from the same place.
Rate limiting by IP address
// A logged-out visitor has no capability to check, so this counts
// requests per IP address instead, and blocks once the limit is hit.
function ai_course_ch25_check_rate_limit( WP_REST_Request $request ) {
$ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';
$key = 'ai_course_ch25_rate_' . md5( $ip );
$count = (int) get_transient( $key );
if ( $count >= 5 ) {
return new WP_Error(
'ai_course_rate_limited',
'Too many questions right now, try again in a minute.',
array( 'status' => 429 )
);
}
set_transient( $key, $count + 1, MINUTE_IN_SECONDS );
return true;
}
A transient, keyed to a hash of the visitor’s IP address, counts requests within a one-minute window. Once that count hits 5, the function returns a WP_Error instead of true. The REST API treats that exactly like a failed permission check, the request never reaches ai_course_ch25_answer_question() at all.
Returning true is what actually allows the request through. This function is a real permission callback, it just isn’t checking who someone is, only how often they’ve asked.
A note about the nonce here
The widget still sends a nonce with every request, the same wp.apiFetch.createNonceMiddleware() pattern from Chapter 20. Worth understanding what it actually protects for a logged-out visitor, though, since it’s weaker here than it was for a logged-in user.
WordPress’s wp_rest nonce is partly based on who’s making the request. For every anonymous visitor at any given moment, that identity is the same: “nobody’s logged in.” So the nonce value ends up shared across every logged-out visitor on the site within the same time window, not unique per person the way it would be for a signed-in user.
It still proves the request came from a page your site actually rendered, rather than an arbitrary script somewhere else on the internet, that’s real protection. It just isn’t identifying anyone, and it isn’t what stops abuse here. The rate limiter is doing that job.
Answering only from your own content
// Answers the question, but only using content from a designated FAQ
// page, not the model's own general knowledge.
function ai_course_ch25_answer_question( WP_REST_Request $request ) {
$question = $request->get_param( 'question' );
$faq_page = get_page_by_path( 'faq' );
if ( ! $faq_page ) {
return new WP_Error( 'ai_course_no_faq_page', 'No FAQ page found. Create a page with the slug "faq" first.' );
}
$faq_content = wp_strip_all_tags( $faq_page->post_content );
$result = wp_ai_client_prompt( $question )
->using_system_instruction( "Answer the visitor's question using only the following FAQ content. If the answer isn't in this content, say you don't know rather than guessing:\n\n{$faq_content}" )
->generate_text_result();
return rest_ensure_response( $result );
}
This looks up a page with the slug faq, strips its HTML down to plain text, and hands that content to the model as a system instruction, the same mechanism Chapter 5 introduced. The visitor’s actual question becomes the prompt.
Answering only from a fixed page of your own content, rather than the model’s own general knowledge, is what keeps this a real FAQ widget instead of a generic chatbot that happens to live on your site.
The widget itself
There’s one more function to cover: ai_course_ch25_faq_widget(), the one registered as the [ai_course_ch25] shortcode.
function ai_course_ch25_faq_widget() {
return '
<div id="ai-course-ch25-faq">
<input type="text" id="ai-course-ch25-question" placeholder="Ask a question..." style="width:100%; max-width:400px;">
<button id="ai-course-ch25-button">Ask</button>
<p id="ai-course-ch25-answer"></p>
</div>
';
}
add_shortcode( 'ai_course_ch25', 'ai_course_ch25_faq_widget' );
Plain HTML, nothing AI-specific about it: an input field, a button, and an empty paragraph the answer gets written into. The three IDs, ai-course-ch25-question, ai-course-ch25-button, ai-course-ch25-answer, are what the JavaScript file actually looks for. That’s the only real connection between this function and everything else in the chapter.
The JavaScript file does the rest, covered in detail once the full file is shown below.
Putting it together
Create a page with the slug faq, with real question-and-answer content in it, this is what the widget actually answers from. Then create chapter-25-faq-widget.php inside includes:
<?php
/**
* Chapter 25: A Public AI-Powered FAQ / Search Widget
* Usage: create a page with the slug "faq" containing your actual FAQ
* content, then add [ai_course_ch25] to any page or post. Works for
* logged-out visitors, no login required.
*/
if ( ! defined( 'ABSPATH' ) ) {
exit; // No direct access.
}
// Registers the REST endpoint the widget's JavaScript will call.
function ai_course_ch25_register_rest_route() {
register_rest_route(
'ai-course/v1',
'/faq',
array(
'methods' => 'POST',
'callback' => 'ai_course_ch25_answer_question',
// No current_user_can() here, on purpose, see the rate
// limiter below for what actually gates this endpoint.
'permission_callback' => 'ai_course_ch25_check_rate_limit',
'args' => array(
'question' => array(
'required' => true,
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
),
),
)
);
}
add_action( 'rest_api_init', 'ai_course_ch25_register_rest_route' );
// A logged-out visitor has no capability to check, so this counts
// requests per IP address instead, and blocks once the limit is hit.
function ai_course_ch25_check_rate_limit( WP_REST_Request $request ) {
$ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';
$key = 'ai_course_ch25_rate_' . md5( $ip );
$count = (int) get_transient( $key );
if ( $count >= 5 ) {
return new WP_Error(
'ai_course_rate_limited',
'Too many questions right now, try again in a minute.',
array( 'status' => 429 )
);
}
set_transient( $key, $count + 1, MINUTE_IN_SECONDS );
return true;
}
// Answers the question, but only using content from a designated FAQ
// page, not the model's own general knowledge.
function ai_course_ch25_answer_question( WP_REST_Request $request ) {
$question = $request->get_param( 'question' );
$faq_page = get_page_by_path( 'faq' );
if ( ! $faq_page ) {
return new WP_Error( 'ai_course_no_faq_page', 'No FAQ page found. Create a page with the slug "faq" first.' );
}
$faq_content = wp_strip_all_tags( $faq_page->post_content );
$result = wp_ai_client_prompt( $question )
->using_system_instruction( "Answer the visitor's question using only the following FAQ content. If the answer isn't in this content, say you don't know rather than guessing:\n\n{$faq_content}" )
->generate_text_result();
return rest_ensure_response( $result );
}
// Loads the widget's JavaScript on the frontend.
function ai_course_ch25_enqueue_assets() {
wp_enqueue_script(
'ai-course-ch25-faq',
plugins_url( 'js/chapter-25-faq-widget.js', __FILE__ ),
array( 'wp-api-fetch' ),
'1.0',
true
);
// The nonce middleware setup needs to run before the click handler,
// so it's added as a separate, earlier inline script.
wp_add_inline_script(
'ai-course-ch25-faq',
sprintf(
'wp.apiFetch.use( wp.apiFetch.createNonceMiddleware( %s ) );',
wp_json_encode( wp_create_nonce( 'wp_rest' ) )
),
'before'
);
}
add_action( 'wp_enqueue_scripts', 'ai_course_ch25_enqueue_assets' );
function ai_course_ch25_faq_widget() {
return '
<div id="ai-course-ch25-faq">
<input type="text" id="ai-course-ch25-question" placeholder="Ask a question..." style="width:100%; max-width:400px;">
<button id="ai-course-ch25-button">Ask</button>
<p id="ai-course-ch25-answer"></p>
</div>
';
}
add_shortcode( 'ai_course_ch25', 'ai_course_ch25_faq_widget' );
Then create chapter-25-faq-widget.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_ch25_init() {
const button = document.getElementById( 'ai-course-ch25-button' );
if ( ! button ) {
return;
}
button.addEventListener( 'click', () => {
const question = document.getElementById( 'ai-course-ch25-question' ).value;
const answerBox = document.getElementById( 'ai-course-ch25-answer' );
if ( ! question ) {
return;
}
answerBox.textContent = 'Thinking...';
wp.apiFetch( {
path: '/ai-course/v1/faq',
method: 'POST',
data: { question }
} )
.then( ( data ) => {
let answer = '';
if ( data.candidates && data.candidates[ 0 ] && data.candidates[ 0 ].message ) {
data.candidates[ 0 ].message.parts.forEach( ( part ) => {
if ( part.text ) {
answer += part.text;
}
} );
}
answerBox.textContent = answer || 'Could not find an answer.';
} )
.catch( ( error ) => {
// A 429 here means the rate limiter kicked in.
answerBox.textContent = error.message || 'Something went wrong, please try again.';
} );
} );
}
if ( 'loading' === document.readyState ) {
document.addEventListener( 'DOMContentLoaded', ai_course_ch25_init );
} else {
ai_course_ch25_init();
}
The click handler reads the question from the input, and does nothing if it’s empty. Otherwise it shows “Thinking…” in the answer box and calls /ai-course/v1/faq with wp.apiFetch(), the same method used since Chapter 20.
The response gets parsed the same confirmed way every chapter since has: pulling the actual text out of data.candidates[0].message.parts. If that ever comes back empty, “Could not find an answer” shows instead of leaving the box blank.
.catch() is what surfaces the rate limiter’s message if you ask too many questions too quickly. error.message carries the real “Too many questions right now…” text straight from the server, not a generic fallback.
Add [ai_course_ch25] to a page, log out (or open the page in a private browsing window), and try it. Ask a question your FAQ page actually answers, then try one it doesn’t, you should get an honest “I don’t know” instead of a made-up answer. Ask six questions in quick succession, and the sixth should come back rate-limited instead of generating anything.
Try it yourself
Lower the rate limit to something you can actually trigger while testing, 2 requests per minute instead of 5, and confirm the 429 error actually shows up in the widget once you go over it.
For a bigger site than one FAQ page
This chapter puts a whole page’s content into the prompt. That works for a page or two. It won’t work for hundreds of posts, there’s too much text to fit, and sending it all on every question gets expensive fast.
For a bigger site, you’d want vector search instead. Your content gets converted into embeddings, a numeric way of representing meaning, not just keywords, and stored in a vector database. When someone asks a question, you find the few most relevant pieces of content first, then send just those to the AI.
The WordPress AI Client doesn’t support this yet. There’s no embeddings API right now. It’s not abandoned, though, the WordPress core team’s own notes confirm embeddings support is planned for a release after 7.1, and they want it finished well before that deadline.
If you need this today, real options already exist. wpvdb, a plugin from Automattic, stores embeddings directly in WordPress’s own database. A few other plugins and hosted services do something similar, they just talk to embedding providers directly instead of going through the WordPress AI Client.