Backend Wiring

Wiring the Widget to a WordPress REST Endpoint

⏱ 16 min

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.

What you'll learn in this lesson
Registering a custom REST route
register_rest_route() with its own namespace, separate from WordPress core's.
A permission_callback that checks a nonce, not a login
Basic request legitimacy for an anonymous visitor, not full authentication.
Calling wp_ai_client_prompt() safely from the callback
Sanitizing input, handling WP_Error, and returning a clean JSON reply.
Updating the widget's fetch call
Wiring the real endpoint in, replacing Lesson 2's static placeholder.
Prerequisites

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;
}
A nonce proves the request came from your page, not who the visitor is

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.

Skipping is_wp_error() here is the same mistake Course 1 warned about

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-Nonce header
  • 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

← Building the Chat Widget UI Handling Sessions and Conversation Context →