Lesson 1 stated the principle, the browser never talks to your AI provider directly.
This lesson builds the piece that makes it true: a custom REST route, registered with
register_rest_route(), whose PHP callback is the only code in this entire system that
calls wp_ai_client_prompt(). The widget’s sendMessage() stub from Lesson 2 gets
replaced with a real fetch() call to this endpoint, and nothing else changes on the
JavaScript side.
Lesson 2’s widget UI and enqueue setup, and a working wp_ai_client_prompt() call from
WordPress AI Foundations.
Step 1: register the route
// File: inc/rest-chat-endpoint.php
add_action( 'rest_api_init', function () {
register_rest_route( 'my-plugin/v1', '/chat', array(
'methods' => 'POST',
'callback' => 'my_plugin_handle_chat_request',
'permission_callback' => 'my_plugin_chat_permission_check',
'args' => array(
'message' => array(
'type' => 'string',
'required' => true,
),
'session_id' => array(
'type' => 'string',
'required' => true,
),
),
) );
} );
This creates POST /wp-json/my-plugin/v1/chat, entirely separate from any core
WordPress REST route, and entirely separate from the MCP Adapter’s routes covered in
earlier courses. There’s no overlap between this endpoint and anything an authenticated
MCP client would use, this one is deliberately built for an anonymous caller.
Step 2: a permission_callback that checks a nonce, not a user
There’s no WordPress user to check current_user_can() against, so this
permission_callback does the only thing that makes sense for an anonymous visitor:
confirm the request carries a nonce that was actually issued by this site, via the
X-WP-Nonce header the widget sends with every request.
// File: inc/rest-chat-endpoint.php
function my_plugin_chat_permission_check( WP_REST_Request $request ) {
$nonce = $request->get_header( 'X-WP-Nonce' );
if ( ! $nonce || ! wp_verify_nonce( $nonce, 'wp_rest' ) ) {
return new WP_Error(
'invalid_nonce',
'Missing or invalid request token.',
array( 'status' => 403 )
);
}
return true;
}
wp_verify_nonce() here confirms the request originated from a page your site actually
rendered, recently, rather than being replayed or forged from an arbitrary script
elsewhere on the internet. It is not authentication, an anonymous visitor still has no
identity behind this check. Treat it as one layer, alongside the rate limiting Lesson 6
adds, not as a substitute for either.
Step 3: the callback that actually calls the model
// File: inc/rest-chat-endpoint.php
function my_plugin_handle_chat_request( WP_REST_Request $request ) {
$message = sanitize_textarea_field( $request->get_param( 'message' ) );
if ( '' === trim( $message ) ) {
return new WP_Error( 'empty_message', 'Message cannot be empty.', array( 'status' => 400 ) );
}
$result = wp_ai_client_prompt( $message )
->using_system_instruction(
'You are a helpful assistant answering visitor questions on this website. ' .
'Keep answers short and plain. If you are not sure of an answer, say so ' .
'rather than guessing.'
)
->using_max_tokens( 400 )
->generate_text();
if ( is_wp_error( $result ) ) {
return new WP_Error( 'ai_call_failed', 'Something went wrong generating a reply.', array( 'status' => 502 ) );
}
return rest_ensure_response( array(
'reply' => $result,
) );
}
This function is the only place in the entire plugin that touches
wp_ai_client_prompt(). The provider API key it depends on lives in whatever
server-side option or constant your AI provider plugin stores it in, never in a
variable this code sends back to the browser. Notice the callback also never echoes
$message back unsanitized or logs it somewhere a visitor’s browser could later read,
the JSON response contains exactly one field, reply.
generate_text() returns a WP_Error on failure, not a thrown exception. Returning
$result directly to rest_ensure_response() without checking is_wp_error() first
means a failed provider call gets serialized as if it were a real reply, a confusing,
silent failure mode for a visitor to hit.
Step 4: wire the widget’s fetch call in
Replace Lesson 2’s sendMessage() stub with a real call to the endpoint, using the
restUrl and nonce that wp_localize_script() already made available:
// File: assets/js/chat-widget.js
function sendMessage( text ) {
fetch( siteChatWidget.restUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': siteChatWidget.nonce,
},
body: JSON.stringify( {
message: text,
session_id: getOrCreateSessionId(),
} ),
} )
.then( function ( response ) {
return response.json();
} )
.then( function ( data ) {
appendMessage( 'assistant', data.reply || 'Sorry, something went wrong.' );
} )
.catch( function () {
appendMessage( 'assistant', 'Sorry, something went wrong.' );
} );
}
function getOrCreateSessionId() {
// Built out fully in the next lesson, a placeholder for now.
return 'temp-session';
}
The request body now carries a session_id, the endpoint already declares it as a
required arg, even though the callback doesn’t do anything with it yet, that’s Lesson
4’s job.
Test it: send a real message end to end
Reload the page so the enqueued script picks up the new code, open the widget, and send
a message. In the browser’s network tab, confirm the request to /wp-json/my-plugin/v1/chat:
- Carries an
X-WP-Nonceheader - Returns a JSON body shaped like
{"reply": "..."} - Never includes anything resembling an API key, anywhere in the request or response
You can also call it directly to confirm the permission check works:
curl -X POST "https://your-site.test/wp-json/my-plugin/v1/chat" \
-H "Content-Type: application/json" \
-d '{"message":"hello","session_id":"test"}'
Without a valid X-WP-Nonce header, this should return the invalid_nonce error with a
403 status, not a generated reply.
Recap
A custom REST route, registered with register_rest_route(), is the entire bridge
between the anonymous widget and the AI provider. Its permission_callback checks a
nonce for basic request legitimacy since there’s no logged-in user to check against, and
its callback is the only code that calls wp_ai_client_prompt(), checking is_wp_error()
before ever returning a result. The widget’s JavaScript only ever talks to this one
endpoint on your own domain, exactly the architecture Lesson 1 called for.
Resources & further reading
- Adding custom REST API endpoints, developer.wordpress.org
- wp_verify_nonce() function reference, developer.wordpress.org
- PHP AI Client SDK repository, GitHub