Chapter 29
Capstone 2: AI Content Block + Frontend Widget
Custom block (Module 6) + public REST + history (Module 7) — a block editor tool and the matching frontend-facing experience it powers
This chapter builds a block you insert into a post, “Ask About This Page,” and the multi-turn Q&A widget it shows to visitors, grounded in that specific post’s own content, not a general chatbot, not a separate FAQ page. Three earlier chapters, combined: block registration from Chapter 22, the public REST pattern from Chapter 25, and with_history() from Chapter 16.
A block that needs to know which post it’s on
Chapter 22’s block saved its own content directly, whatever the editor typed became the post’s actual HTML. This block can’t work that way. It needs to know, reliably, which post it’s answering questions about. The safest place to get that is the server, at the moment the page is actually being rendered.
That’s what a dynamic block is for. Instead of saving content in the editor, it saves nothing, and a PHP function renders the real output every time the page loads.
// A dynamic block, render_callback runs on the server every time the
// page loads, rather than relying on content saved in the editor.
function ai_course_ch29_register_block() {
register_block_type(
'ai-course/qa-widget',
array(
'render_callback' => 'ai_course_ch29_render_block',
)
);
}
add_action( 'init', 'ai_course_ch29_register_block' );
register_block_type() has one difference from Chapter 22’s version: a render_callback. That’s what runs a PHP function to produce the real output, instead of relying on saved editor content.
Rendering with the real post ID
function ai_course_ch29_render_block() {
// Reliable here specifically because this only ever runs while a
// real post is actually being rendered.
$post_id = get_the_ID();
if ( ! $post_id ) {
return '';
}
// The post ID travels to the browser as a data attribute, that's
// how the JS and the REST request both know which post this is.
return '
<div class="ai-course-ch29-widget" data-post-id="' . esc_attr( $post_id ) . '" style="margin-top:20px;">
<div class="ai-course-ch29-messages" style="margin-bottom:10px;"></div>
<input type="text" class="ai-course-ch29-input" placeholder="Ask a question about this page..." style="width:70%; max-width:400px; padding:8px;">
<button class="ai-course-ch29-send" style="padding:8px 16px;">Ask</button>
</div>
';
}
get_the_ID() works here precisely because this function only ever runs while a real post is being rendered, the same reliability the render callback exists for. The post’s ID gets embedded as a data-post-id attribute. That’s how both the JavaScript and the REST request know which post’s content to ground answers in.
Nothing here is AI-specific, an input, a button, an empty div for messages. The post ID sitting in a data attribute is what makes everything downstream actually work.
Grounding the answer in this specific post
function ai_course_ch29_answer( WP_REST_Request $request ) {
$message = $request->get_param( 'message' );
$post_id = $request->get_param( 'post_id' );
$raw_history = $request->get_param( 'history' );
// Only ever answer using a post that's genuinely public, not a
// draft, not something the visitor shouldn't be able to see.
$post = get_post( $post_id );
if ( ! $post || 'publish' !== $post->post_status ) {
return new WP_Error( 'ai_course_invalid_post', 'That page could not be found.' );
}
// Plain text only, no shortcodes or markup for the model to
// stumble over.
$content = wp_strip_all_tags( strip_shortcodes( $post->post_content ) );
// Same reconstruction as Chapter 27: plain {role, text} pairs from
// the browser, turned back into real message objects.
$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 ) ) );
}
}
// Grounds every answer in this specific post's content, and asks
// the model to admit when the content doesn't actually cover it.
$result = wp_ai_client_prompt( $message )
->using_system_instruction( "Answer questions using only the following page content. If the answer isn't in it, say so honestly instead of guessing:\n\n{$content}" )
->with_history( ...$history )
->generate_text_result();
return rest_ensure_response( $result );
}
Two grounding techniques, doing different jobs, in the same function.
using_system_instruction() is what actually grounds this in the post’s own content, applied fresh per-post instead of to one fixed page the way Chapter 25’s FAQ widget used it.
with_history() is what makes follow-up questions work. Without it, “what about the second one?” means nothing, the model would have no idea what “the second one” refers to. The history array gets rebuilt into real UserMessage and ModelMessage objects the same way Chapter 27’s chat widget did, the browser keeps the conversation and resends it every time.
A public endpoint, same defense as Chapters 25 and 27
function ai_course_ch29_check_rate_limit( WP_REST_Request $request ) {
$ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';
$key = 'ai_course_ch29_rate_' . md5( $ip );
$count = (int) get_transient( $key );
if ( $count >= 10 ) {
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;
}
Any visitor on any page with this block can ask questions, no login required, so this needs the same defense every public endpoint in this book has needed. The function is identical to Chapter 27’s, reused as-is rather than rebuilt.
Putting it together
Create chapter-29-content-block.php inside includes:
<?php
/**
* Chapter 29: Capstone 2, AI Content Block + Frontend Widget
* Usage: add the "Ask About This Page" block to any post or page.
* Visitors can ask questions grounded in that page's own content.
*/
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;
// A dynamic block, render_callback runs on the server every time the
// page loads, rather than relying on content saved in the editor.
function ai_course_ch29_register_block() {
register_block_type(
'ai-course/qa-widget',
array(
'render_callback' => 'ai_course_ch29_render_block',
)
);
}
add_action( 'init', 'ai_course_ch29_register_block' );
function ai_course_ch29_render_block() {
// Reliable here specifically because this only ever runs while a
// real post is actually being rendered.
$post_id = get_the_ID();
if ( ! $post_id ) {
return '';
}
// The post ID travels to the browser as a data attribute, that's
// how the JS and the REST request both know which post this is.
return '
<div class="ai-course-ch29-widget" data-post-id="' . esc_attr( $post_id ) . '" style="margin-top:20px;">
<div class="ai-course-ch29-messages" style="margin-bottom:10px;"></div>
<input type="text" class="ai-course-ch29-input" placeholder="Ask a question about this page..." style="width:70%; max-width:400px; padding:8px;">
<button class="ai-course-ch29-send" style="padding:8px 16px;">Ask</button>
</div>
';
}
function ai_course_ch29_register_rest_route() {
register_rest_route(
'ai-course/v1',
'/qa-widget',
array(
'methods' => 'POST',
'callback' => 'ai_course_ch29_answer',
'permission_callback' => 'ai_course_ch29_check_rate_limit',
'args' => array(
'message' => array(
'required' => true,
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
),
'post_id' => array(
'required' => true,
'type' => 'integer',
'sanitize_callback' => 'absint',
),
'history' => array(
'required' => false,
'type' => 'array',
'default' => array(),
),
),
)
);
}
add_action( 'rest_api_init', 'ai_course_ch29_register_rest_route' );
// Reused as-is from Chapter 27, no need to rebuild the same defense twice.
function ai_course_ch29_check_rate_limit( WP_REST_Request $request ) {
$ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';
$key = 'ai_course_ch29_rate_' . md5( $ip );
$count = (int) get_transient( $key );
if ( $count >= 10 ) {
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;
}
function ai_course_ch29_answer( WP_REST_Request $request ) {
$message = $request->get_param( 'message' );
$post_id = $request->get_param( 'post_id' );
$raw_history = $request->get_param( 'history' );
// Only ever answer using a post that's genuinely public, not a
// draft, not something the visitor shouldn't be able to see.
$post = get_post( $post_id );
if ( ! $post || 'publish' !== $post->post_status ) {
return new WP_Error( 'ai_course_invalid_post', 'That page could not be found.' );
}
// Plain text only, no shortcodes or markup for the model to
// stumble over.
$content = wp_strip_all_tags( strip_shortcodes( $post->post_content ) );
// Same reconstruction as Chapter 27: plain {role, text} pairs from
// the browser, turned back into real message objects.
$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 ) ) );
}
}
// Grounds every answer in this specific post's content, and asks
// the model to admit when the content doesn't actually cover it.
$result = wp_ai_client_prompt( $message )
->using_system_instruction( "Answer questions using only the following page content. If the answer isn't in it, say so honestly instead of guessing:\n\n{$content}" )
->with_history( ...$history )
->generate_text_result();
return rest_ensure_response( $result );
}
function ai_course_ch29_enqueue_assets() {
// No point loading this on every page, only where the block
// actually appears.
if ( ! has_block( 'ai-course/qa-widget' ) ) {
return;
}
wp_enqueue_script(
'ai-course-ch29-qa-widget',
plugins_url( 'js/chapter-29-content-block.js', __FILE__ ),
array( 'wp-api-fetch' ),
'1.0',
true
);
wp_add_inline_script(
'ai-course-ch29-qa-widget',
sprintf(
'wp.apiFetch.use( wp.apiFetch.createNonceMiddleware( %s ) );',
wp_json_encode( wp_create_nonce( 'wp_rest' ) )
),
'before'
);
}
add_action( 'wp_enqueue_scripts', 'ai_course_ch29_enqueue_assets' );
// A separate script, loaded only in the editor, for registering the
// block itself, not the frontend widget it renders.
function ai_course_ch29_enqueue_editor_assets() {
wp_enqueue_script(
'ai-course-ch29-editor',
plugins_url( 'js/chapter-29-editor.js', __FILE__ ),
array( 'wp-blocks', 'wp-element', 'wp-block-editor' ),
'1.0',
true
);
}
add_action( 'enqueue_block_editor_assets', 'ai_course_ch29_enqueue_editor_assets' );
Then create chapter-29-editor.js inside includes/js:
( function ( blocks, element ) {
const el = element.createElement;
// A dynamic block's edit() function only affects what you see in
// the editor, the real content comes from the render_callback,
// so this just needs a plain placeholder.
blocks.registerBlockType( 'ai-course/qa-widget', {
title: 'Ask About This Page',
icon: 'format-chat',
category: 'widgets',
edit: function () {
return el(
'div',
{ style: { padding: '20px', border: '1px dashed #ccc' } },
'Visitors will be able to ask questions about this page here.'
);
},
// Dynamic blocks save nothing, render_callback produces the
// real output on the frontend.
save: function () {
return null;
},
} );
} )( window.wp.blocks, window.wp.element );
And chapter-29-content-block.js inside includes/js:
function ai_course_ch29_init() {
// A page could have more than one of these blocks, so this loops
// over every instance instead of assuming just one.
document.querySelectorAll( '.ai-course-ch29-widget' ).forEach( ( widget ) => {
const postId = widget.getAttribute( 'data-post-id' );
const messagesBox = widget.querySelector( '.ai-course-ch29-messages' );
const input = widget.querySelector( '.ai-course-ch29-input' );
const sendButton = widget.querySelector( '.ai-course-ch29-send' );
// Same client-side history pattern as Chapter 27, the server
// keeps nothing between requests, so this gets resent every time.
let history = [];
// Reused as-is from Chapter 27. Escapes HTML first, so nothing
// in the raw text can inject real markup, then applies a
// narrow set of markdown rules that only ever produce a fixed
// set of safe tags.
function renderMarkdown( text ) {
let html = text
.replace( /&/g, '&' )
.replace( /</g, '<' )
.replace( />/g, '>' );
html = html.replace( /```[a-z]*\n?([\s\S]*?)```/g, ( match, code ) => '<pre><code>' + code.trim() + '</code></pre>' );
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;
}
// Returns the bubble it creates, 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.fontWeight = role === 'user' ? 'bold' : 'normal';
messagesBox.appendChild( bubble );
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...' );
// post_id is the one real addition over Chapter 27, it's
// what tells the server which page to ground the answer in.
wp.apiFetch( {
path: '/ai-course/v1/qa-widget',
method: 'POST',
data: { message, post_id: postId, 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 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.
if ( 'loading' === document.readyState ) {
document.addEventListener( 'DOMContentLoaded', ai_course_ch29_init );
} else {
ai_course_ch29_init();
}
Add the “Ask About This Page” block to a post with real content, publish it, and open it as a visitor would. Ask something the page actually answers, then a follow-up that only makes sense in context.
Then ask something the page doesn’t cover, the answer should say so honestly instead of making something up. That’s the system instruction doing its own job here, not just an earlier pattern repeated unchanged.
Walking through the widget’s own logic
This script handles something new: more than one instance on the same page. querySelectorAll( '.ai-course-ch29-widget' ) finds every copy of this widget. .forEach() sets each one up separately, with its own history array and its own message panel, completely independent of any others.
Inside each one, addMessage() and sendMessage() work the same way they did in Chapter 27. addMessage() adds one line to the message panel and returns it, so the caller can update it later. sendMessage() shows your question right away, then adds a “Thinking…” placeholder and keeps a reference to it as thinkingBubble.
renderMarkdown() is also reused as-is from Chapter 27. Replies grounded in real content tend to come back with real formatting, bold terms, bullet points, and this is what renders it safely instead of showing raw markdown.
The apiFetch() call now includes post_id too, so the server knows which page’s content to ground the answer in. Once a reply comes back, thinkingBubble‘s content gets replaced in place, rather than a second bubble appearing next to it.
The readyState check at the bottom is the same defensive pattern from every frontend chapter in this book. If this script loads after the page has already finished rendering, DOMContentLoaded may never fire again. The code runs immediately in that case, instead of waiting for an event that already happened.
Try it yourself
Add a second block variant that answers using an entire category’s worth of posts instead of just one, useful for something like a documentation section spread across several pages, rather than a single all-in-one FAQ page like Chapter 25’s.
For readers who already know JSX
chapter-29-editor.js uses wp.element.createElement() instead of JSX, for the reasons explained in this module’s introduction. This block is simpler than Chapter 22’s, no state, no API call in the editor itself, so the JSX version is correspondingly short: just the import and the two return statements would actually look different.
The import:
import { registerBlockType } from '@wordpress/blocks';
edit()‘s return statement:
return (
<div style={ { padding: '20px', border: '1px dashed #ccc' } }>
Visitors will be able to ask questions about this page here.
</div>
);
save()‘s return statement:
return null;
That last one looks almost too simple to be right, but it is. A dynamic block’s save() genuinely has nothing to output, render_callback does all the real work, in JSX or in createElement(), either way.