Chapter 27
A Multi-Turn Chatbot Widget on the Frontend
Combining with_history() (Ep. 16) with the REST pattern (Ep. 20) for a stateful frontend chat
Chapter 16 promised this chapter would come. with_history() worked there, but on a hardcoded, fake history, the same two messages every time. This chapter makes it real: a floating chat widget where the conversation actually grows, one exchange at a time.
Where the conversation actually lives
Nothing on the server remembers your last message. Every REST request in this book has been independent of the one before it, and this chapter doesn’t change that. Instead, the browser keeps the whole conversation itself, and resends it with every new message.
// The whole conversation lives here, in the browser. The server
// keeps nothing between requests, so this gets resent every time.
let history = [];
Each time you send a message, that array grows by two entries: what you said, and what the model replied. The entire thing gets sent along with your next message. The server never has to remember anything, because the browser hands it everything it needs each time.
Sending it to the server
That array only matters once it’s actually sent somewhere. This is the call that does it, the same wp.apiFetch() method every frontend chapter in this book has used:
wp.apiFetch( {
path: '/ai-course/v1/chat',
method: 'POST',
data: { message, history }
} )
message is whatever you just typed. history is the array from the last section, everything said so far in this conversation. Both travel together, every single time. That’s the actual link between “the browser remembers the conversation” and “the server finds out what was said.”
Registering the endpoint
function ai_course_ch27_register_rest_route() {
register_rest_route(
'ai-course/v1',
'/chat',
array(
'methods' => 'POST',
'callback' => 'ai_course_ch27_chat',
// No logged-in user to check, so the rate limiter below
// is what actually gates this endpoint.
'permission_callback' => 'ai_course_ch27_check_rate_limit',
'args' => array(
'message' => array(
'required' => true,
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
),
'history' => array(
'required' => false,
'type' => 'array',
'default' => array(),
),
),
)
);
}
add_action( 'rest_api_init', 'ai_course_ch27_register_rest_route' );
Two arguments, matching exactly what the JS just sent: message, a required string, and history, an optional array that defaults to empty for the very first message in a new conversation.
Rebuilding the conversation on the server
// Rebuilds the conversation from what the browser sent back, then asks
// for the next reply with that full history attached.
function ai_course_ch27_chat( WP_REST_Request $request ) {
$message = $request->get_param( 'message' );
$raw_history = $request->get_param( 'history' );
$history = array();
foreach ( (array) $raw_history as $turn ) {
if ( empty( $turn['role'] ) || empty( $turn['text'] ) ) {
continue;
}
$text = sanitize_text_field( $turn['text'] );
if ( 'user' === $turn['role'] ) {
$history[] = new UserMessage( array( new MessagePart( $text ) ) );
} elseif ( 'model' === $turn['role'] ) {
$history[] = new ModelMessage( array( new MessagePart( $text ) ) );
}
}
$result = wp_ai_client_prompt( $message )
->with_history( ...$history )
->generate_text_result();
return rest_ensure_response( $result );
}
This is where the history array from the last two sections actually gets used. It arrives here as plain { role, text } pairs. JSON has no concept of UserMessage or ModelMessage, so this function’s job is turning that plain data back into the actual message objects Chapter 16 introduced, one per turn, before handing the whole rebuilt history to with_history().
Reusing Chapter 25’s defenses
This widget is public, so it needs the same protection Chapter 25 built: rate limiting instead of a login check, with a higher limit here since a real conversation runs longer than a single question.
// Reused as-is from Chapter 25, no need to rebuild the same defense twice.
function ai_course_ch27_check_rate_limit( WP_REST_Request $request ) {
$ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';
$key = 'ai_course_ch27_rate_' . md5( $ip );
$count = (int) get_transient( $key );
if ( $count >= 10 ) {
return new WP_Error(
'ai_course_rate_limited',
'Too many messages right now, try again in a minute.',
array( 'status' => 429 )
);
}
set_transient( $key, $count + 1, MINUTE_IN_SECONDS );
return true;
}
This is the same function, unchanged. A defense you’ve already built once doesn’t need reinventing every time a new public endpoint needs it.
Loading the widget
Two more functions get everything onto the page:
// Loads the widget's JavaScript on the frontend.
function ai_course_ch27_enqueue_assets() {
wp_enqueue_script(
'ai-course-ch27-chat',
plugins_url( 'js/chapter-27-chat-widget.js', __FILE__ ),
array( 'wp-api-fetch' ),
'1.0',
true
);
wp_add_inline_script(
'ai-course-ch27-chat',
sprintf(
'wp.apiFetch.use( wp.apiFetch.createNonceMiddleware( %s ) );',
wp_json_encode( wp_create_nonce( 'wp_rest' ) )
),
'before'
);
}
add_action( 'wp_enqueue_scripts', 'ai_course_ch27_enqueue_assets' );
function ai_course_ch27_chat_widget() {
return '
<div id="ai-course-ch27-widget" style="position:fixed; bottom:20px; right:20px; z-index:9999; font-family:sans-serif; font-size:13px;">
<button id="ai-course-ch27-toggle" style="border-radius:50%; width:50px; height:50px; font-size:13px;">Chat</button>
<div id="ai-course-ch27-panel" style="display:none; width:300px; height:400px; border:1px solid #ccc; background:#fff; position:absolute; bottom:60px; right:0; padding:10px; box-sizing:border-box;">
<div id="ai-course-ch27-messages" style="height:300px; overflow-y:auto; margin-bottom:10px; font-size:13px;"></div>
<input type="text" id="ai-course-ch27-input" placeholder="Type a message..." style="width:70%; font-size:13px;">
<button id="ai-course-ch27-send" style="font-size:13px;">Send</button>
</div>
</div>
';
}
add_shortcode( 'ai_course_ch27', 'ai_course_ch27_chat_widget' );
ai_course_ch27_enqueue_assets() loads the JS file and sets up the nonce, the same pattern every frontend chapter in this module has used.
ai_course_ch27_chat_widget() is the [ai_course_ch27] shortcode itself, plain HTML: a round toggle button, and a hidden panel beside it holding the message list, the input, and the send button. Nothing in this markup is AI-specific. It’s the JavaScript that brings it to life.
Showing messages in the panel
Two pieces of JavaScript control what’s actually visible: the toggle button, and the function that adds each message to the list.
toggle.addEventListener( 'click', () => {
panel.style.display = panel.style.display === 'none' ? 'block' : 'none';
} );
// Adds a message bubble and returns it, so the caller can update its
// text later, used for the "Thinking..." placeholder below.
function addMessage( role, text ) {
const bubble = document.createElement( 'div' );
const prefix = role === 'user' ? 'You: ' : 'AI: ';
bubble.innerHTML = prefix + renderMarkdown( text );
bubble.style.margin = '4px 0';
bubble.style.fontWeight = role === 'user' ? 'bold' : 'normal';
messagesBox.appendChild( bubble );
messagesBox.scrollTop = messagesBox.scrollHeight;
return bubble;
}
The toggle’s click handler just shows or hides the panel, nothing more.
addMessage() prefixes each bubble with “You: ” or “AI: “, so it’s clear at a glance who said what, and it returns the bubble element it just created. That return value matters: sendMessage(), covered further down, uses it as a placeholder it can update later, rather than adding a brand new message once the real reply arrives.
Rendering the reply safely
Complex questions often get complex answers. Ask something like “help me build a WordPress theme,” and the reply comes back with real markdown: bold text, code blocks, bullet lists. Shown as plain text, that’s just a wall of stray asterisks and backticks, exactly what happened during testing.
// Turns a handful of common markdown patterns into real HTML, so
// replies with bold text, code, or lists are actually readable
// instead of showing raw asterisks and backticks.
//
// HTML is escaped first, before any markdown is applied, so nothing
// in the raw text, from the AI or from what you typed, can inject
// real markup. Only the specific tags this function adds are real.
function renderMarkdown( text ) {
let html = text
.replace( /&/g, '&' )
.replace( /</g, '<' )
.replace( />/g, '>' );
// Code blocks first, so nothing inside them gets caught by the
// simpler rules below.
html = html.replace( /```[a-z]*\n?([\s\S]*?)```/g, ( match, code ) => '<pre><code>' + code.trim() + '</code></pre>' );
// Then the rest: inline code, headers, bold, bullet points,
// horizontal rules, and finally line breaks.
html = html.replace( /`([^`]+)`/g, '<code>$1</code>' );
html = html.replace( /^### (.+)$/gm, '<strong>$1</strong>' );
html = html.replace( /\*\*([^*]+)\*\*/g, '<strong>$1</strong>' );
html = html.replace( /^\* (.+)$/gm, '• $1<br>' );
html = html.replace( /^---$/gm, '<hr>' );
html = html.replace( /\n/g, '<br>' );
return html;
}
renderMarkdown() turns the common patterns into real HTML instead: code blocks and inline code, bold text, headers, bullet points, horizontal rules, and line breaks. It’s deliberately narrow, covering what actually shows up in typical replies, not a full markdown parser.
The order matters here. HTML is escaped first, turning any literal < or & in the raw text into harmless entities, before any markdown rule runs.
Every rule after that only ever produces a small, fixed set of tags this function controls directly: <strong>, <code>, <pre>, <br>, <hr>. Nothing in the AI’s own text, or in what you type yourself, can inject real markup, only these specific, known-safe replacements can. That’s what makes it safe to use innerHTML here instead of textContent.
Sending a message
function sendMessage() {
const message = input.value.trim();
if ( ! message ) {
return;
}
addMessage( 'user', message );
input.value = '';
sendButton.disabled = true;
// A placeholder bubble shown while waiting, replaced in place
// once the real reply comes back.
const thinkingBubble = addMessage( 'model', 'Thinking...' );
wp.apiFetch( {
path: '/ai-course/v1/chat',
method: 'POST',
data: { message, history }
} )
.then( ( data ) => {
let reply = '';
if ( data.candidates && data.candidates[ 0 ] && data.candidates[ 0 ].message ) {
data.candidates[ 0 ].message.parts.forEach( ( part ) => {
if ( part.text ) {
reply += part.text;
}
} );
}
thinkingBubble.innerHTML = 'AI: ' + renderMarkdown( reply || 'Sorry, something went wrong.' );
// Add both turns to the running history, sent again next time.
history.push( { role: 'user', text: message } );
history.push( { role: 'model', text: reply } );
sendButton.disabled = false;
} )
.catch( ( error ) => {
// Surfaces the real error, a rate-limited message for
// instance, instead of always showing a generic fallback.
thinkingBubble.innerHTML = 'AI: ' + renderMarkdown( error.message || 'Something went wrong, please try again.' );
sendButton.disabled = false;
} );
}
sendMessage() ties everything together. It shows your message right away with addMessage( 'user', message ), then adds a “Thinking…” placeholder and keeps a reference to it as thinkingBubble.
The apiFetch() call sends message and history together. When the reply arrives, both turns get added to history for next time, and thinkingBubble updates in place instead of a new bubble appearing.
If the request fails, .catch() shows the real error instead of a generic message, so you actually know why, the same as Chapter 25’s widget.
Putting it together
Create chapter-27-chat-widget.php inside includes:
<?php
/**
* Chapter 27: A Multi-Turn Chatbot Widget on the Frontend
* Usage: add [ai_course_ch27] to any page or post. Works for logged-out
* visitors, no login required.
*/
if ( ! defined( 'ABSPATH' ) ) {
exit; // No direct access.
}
use WordPress\AiClient\Messages\DTO\UserMessage;
use WordPress\AiClient\Messages\DTO\ModelMessage;
use WordPress\AiClient\Messages\DTO\MessagePart;
// Registers the REST endpoint the widget's JavaScript will call.
function ai_course_ch27_register_rest_route() {
register_rest_route(
'ai-course/v1',
'/chat',
array(
'methods' => 'POST',
'callback' => 'ai_course_ch27_chat',
// No logged-in user to check, so the rate limiter below
// is what actually gates this endpoint.
'permission_callback' => 'ai_course_ch27_check_rate_limit',
'args' => array(
'message' => array(
'required' => true,
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
),
'history' => array(
'required' => false,
'type' => 'array',
'default' => array(),
),
),
)
);
}
add_action( 'rest_api_init', 'ai_course_ch27_register_rest_route' );
// Reused as-is from Chapter 25, no need to rebuild the same defense twice.
function ai_course_ch27_check_rate_limit( WP_REST_Request $request ) {
$ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';
$key = 'ai_course_ch27_rate_' . md5( $ip );
$count = (int) get_transient( $key );
if ( $count >= 10 ) {
return new WP_Error(
'ai_course_rate_limited',
'Too many messages right now, try again in a minute.',
array( 'status' => 429 )
);
}
set_transient( $key, $count + 1, MINUTE_IN_SECONDS );
return true;
}
// Rebuilds the conversation from what the browser sent back, then asks
// for the next reply with that full history attached.
function ai_course_ch27_chat( WP_REST_Request $request ) {
$message = $request->get_param( 'message' );
$raw_history = $request->get_param( 'history' );
$history = array();
foreach ( (array) $raw_history as $turn ) {
if ( empty( $turn['role'] ) || empty( $turn['text'] ) ) {
continue;
}
$text = sanitize_text_field( $turn['text'] );
if ( 'user' === $turn['role'] ) {
$history[] = new UserMessage( array( new MessagePart( $text ) ) );
} elseif ( 'model' === $turn['role'] ) {
$history[] = new ModelMessage( array( new MessagePart( $text ) ) );
}
}
$result = wp_ai_client_prompt( $message )
->with_history( ...$history )
->generate_text_result();
return rest_ensure_response( $result );
}
// Loads the widget's JavaScript on the frontend.
function ai_course_ch27_enqueue_assets() {
wp_enqueue_script(
'ai-course-ch27-chat',
plugins_url( 'js/chapter-27-chat-widget.js', __FILE__ ),
array( 'wp-api-fetch' ),
'1.0',
true
);
wp_add_inline_script(
'ai-course-ch27-chat',
sprintf(
'wp.apiFetch.use( wp.apiFetch.createNonceMiddleware( %s ) );',
wp_json_encode( wp_create_nonce( 'wp_rest' ) )
),
'before'
);
}
add_action( 'wp_enqueue_scripts', 'ai_course_ch27_enqueue_assets' );
function ai_course_ch27_chat_widget() {
return '
<div id="ai-course-ch27-widget" style="position:fixed; bottom:20px; right:20px; z-index:9999; font-family:sans-serif; font-size:13px;">
<button id="ai-course-ch27-toggle" style="border-radius:50%; width:50px; height:50px; font-size:13px;">Chat</button>
<div id="ai-course-ch27-panel" style="display:none; width:300px; height:400px; border:1px solid #ccc; background:#fff; position:absolute; bottom:60px; right:0; padding:10px; box-sizing:border-box;">
<div id="ai-course-ch27-messages" style="height:300px; overflow-y:auto; margin-bottom:10px; font-size:13px;"></div>
<input type="text" id="ai-course-ch27-input" placeholder="Type a message..." style="width:70%; font-size:13px;">
<button id="ai-course-ch27-send" style="font-size:13px;">Send</button>
</div>
</div>
';
}
add_shortcode( 'ai_course_ch27', 'ai_course_ch27_chat_widget' );
Then create chapter-27-chat-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_ch27_init() {
const toggle = document.getElementById( 'ai-course-ch27-toggle' );
const panel = document.getElementById( 'ai-course-ch27-panel' );
const messagesBox = document.getElementById( 'ai-course-ch27-messages' );
const input = document.getElementById( 'ai-course-ch27-input' );
const sendButton = document.getElementById( 'ai-course-ch27-send' );
if ( ! toggle ) {
return;
}
// The whole conversation lives here, in the browser. The server
// keeps nothing between requests, so this gets resent every time.
let history = [];
toggle.addEventListener( 'click', () => {
panel.style.display = panel.style.display === 'none' ? 'block' : 'none';
} );
// Turns a handful of common markdown patterns into real HTML, so
// replies with bold text, code, or lists are actually readable
// instead of showing raw asterisks and backticks.
//
// HTML is escaped first, before any markdown is applied, so nothing
// in the raw text, from the AI or from what you typed, can inject
// real markup. Only the specific tags this function adds are real.
function renderMarkdown( text ) {
let html = text
.replace( /&/g, '&' )
.replace( /</g, '<' )
.replace( />/g, '>' );
// Code blocks first, so nothing inside them gets caught by the
// simpler rules below.
html = html.replace( /```[a-z]*\n?([\s\S]*?)```/g, ( match, code ) => '<pre><code>' + code.trim() + '</code></pre>' );
// Then the rest: inline code, headers, bold, bullet points,
// horizontal rules, and finally line breaks.
html = html.replace( /`([^`]+)`/g, '<code>$1</code>' );
html = html.replace( /^### (.+)$/gm, '<strong>$1</strong>' );
html = html.replace( /\*\*([^*]+)\*\*/g, '<strong>$1</strong>' );
html = html.replace( /^\* (.+)$/gm, '• $1<br>' );
html = html.replace( /^---$/gm, '<hr>' );
html = html.replace( /\n/g, '<br>' );
return html;
}
// Adds a message bubble and returns it, so the caller can update its
// text later, used for the "Thinking..." placeholder below.
function addMessage( role, text ) {
const bubble = document.createElement( 'div' );
const prefix = role === 'user' ? 'You: ' : 'AI: ';
bubble.innerHTML = prefix + renderMarkdown( text );
bubble.style.margin = '4px 0';
bubble.style.fontWeight = role === 'user' ? 'bold' : 'normal';
messagesBox.appendChild( bubble );
messagesBox.scrollTop = messagesBox.scrollHeight;
return bubble;
}
function sendMessage() {
const message = input.value.trim();
if ( ! message ) {
return;
}
addMessage( 'user', message );
input.value = '';
sendButton.disabled = true;
// A placeholder bubble shown while waiting, replaced in place
// once the real reply comes back.
const thinkingBubble = addMessage( 'model', 'Thinking...' );
wp.apiFetch( {
path: '/ai-course/v1/chat',
method: 'POST',
data: { message, history }
} )
.then( ( data ) => {
let reply = '';
if ( data.candidates && data.candidates[ 0 ] && data.candidates[ 0 ].message ) {
data.candidates[ 0 ].message.parts.forEach( ( part ) => {
if ( part.text ) {
reply += part.text;
}
} );
}
thinkingBubble.innerHTML = 'AI: ' + renderMarkdown( reply || 'Sorry, something went wrong.' );
// Add both turns to the running history, sent again next time.
history.push( { role: 'user', text: message } );
history.push( { role: 'model', text: reply } );
sendButton.disabled = false;
} )
.catch( ( error ) => {
// Surfaces the real error, a rate-limited message for
// instance, instead of always showing a generic fallback.
thinkingBubble.innerHTML = 'AI: ' + renderMarkdown( error.message || 'Something went wrong, please try again.' );
sendButton.disabled = false;
} );
}
sendButton.addEventListener( 'click', sendMessage );
input.addEventListener( 'keydown', ( event ) => {
if ( event.key === 'Enter' ) {
sendMessage();
}
} );
}
if ( 'loading' === document.readyState ) {
document.addEventListener( 'DOMContentLoaded', ai_course_ch27_init );
} else {
ai_course_ch27_init();
}
Add [ai_course_ch27] to a page and load it. A small “Chat” button appears in the corner. Click it and ask something.
You should see “You: ” and your message appear immediately, followed by a “Thinking…” placeholder that updates in place once the real reply comes back, prefixed with “AI: “. Ask a follow-up that only makes sense with the first message in mind, “what’s a good name for that?” after describing a project, for instance.
The reply should actually track the conversation, following what you said earlier without you repeating it.
One more detail worth a mention: the JS above checks document.readyState before falling back to DOMContentLoaded. This script loads as an external file, exactly the kind of resource optimization plugins commonly delay, which can mean DOMContentLoaded has already fired by the time it runs, so the check is what makes sure the click handler still gets attached either way.
Worth knowing before you rely on this
The conversation only lives as long as the page does. Refresh, and history resets to an empty array, the widget has no memory of anything said before.
That’s a real limitation for a genuine chat feature. But it’s also exactly the tradeoff of keeping state in the browser instead of the server: no database writes, no cleanup needed, at the cost of the conversation not surviving a reload.
There’s a second thing worth knowing. Nothing verifies that the “model” turns in the history actually came from a real AI response. The browser could send back fabricated ones instead. A visitor could, in theory, inject fake prior replies to try to steer the conversation somewhere it wouldn’t otherwise go.
This isn’t unique to this widget, it’s how most chat APIs handle history. But it’s worth knowing the history you receive is trusted input from the client, not a verified record of what actually happened.
Try it yourself
Add a character or word limit to how much history gets sent. Once a conversation gets long enough, resending the entire thing on every message gets expensive. Keep only the last several turns instead of the full list, and see how far back the model can still follow before it loses the thread.