Chapter 23
Add “Improve with AI” to Existing Blocks
Extending core blocks via block filters/toolbar controls, modifying RichText content
This chapter doesn’t register a new block. It adds a button to a block that already exists, the core Paragraph block, so any paragraph on the page can be rewritten by AI without leaving the editor.
The endpoint
Same shape as every REST endpoint in this book so far, a fixed prompt, one piece of user input filling in the blank:
// Registers the REST endpoint the block filter's JavaScript will call.
function ai_course_ch23_register_rest_route() {
register_rest_route(
'ai-course/v1',
'/improve',
array(
'methods' => 'POST',
'callback' => 'ai_course_ch23_improve_text',
'permission_callback' => function () {
// Only logged-in users who can edit posts may improve text.
return current_user_can( 'edit_posts' );
},
'args' => array(
'text' => array(
'required' => true,
'type' => 'string',
'sanitize_callback' => 'sanitize_textarea_field',
),
),
)
);
}
add_action( 'rest_api_init', 'ai_course_ch23_register_rest_route' );
// Handles the actual REST request: runs the prompt, returns the result.
function ai_course_ch23_improve_text( WP_REST_Request $request ) {
$text = $request->get_param( 'text' );
$result = wp_ai_client_prompt( "Improve the clarity and flow of this text. Keep the same meaning and roughly the same length. Return only the improved text, nothing else:\n\n{$text}" )
->using_temperature( 0.5 )
->generate_text_result();
// rest_ensure_response() handles both success and WP_Error automatically.
return rest_ensure_response( $result );
}
The prompt explicitly asks for “only the improved text, nothing else.” Without that instruction, a model will often add commentary around its answer, “Here’s an improved version:”, explanatory notes, that kind of thing, none of which belongs inside a paragraph block.
Extending a block you didn’t build
Chapter 22 registered a brand new block. This chapter changes an existing one, core/paragraph, without touching WordPress core’s own code. The mechanism is a filter, editor.BlockEdit, which runs on every block’s edit UI and lets you wrap it with something extra.
// Wraps every block's edit UI, but only actually changes anything
// for Paragraph blocks that are currently selected.
const withAIImprove = compose.createHigherOrderComponent( ( BlockEdit ) => {
return ( props ) => {
if ( props.name !== 'core/paragraph' || ! props.isSelected ) {
// Every other block passes through untouched.
return el( BlockEdit, props );
}
// covered below
};
}, 'withAIImprove' );
hooks.addFilter( 'editor.BlockEdit', 'ai-course/improve-with-ai', withAIImprove );
That if check is what keeps this from touching every block on the page. Anything that isn’t a selected Paragraph block passes straight through unchanged, el( BlockEdit, props ) just renders the block exactly as it normally would.
Adding the button and calling the AI
const improveText = () => {
// The block stores its content as an HTML string. Strip
// tags to get plain text for the prompt.
const plainText = props.attributes.content.replace( /<[^>]*>/g, '' );
apiFetch( {
path: '/ai-course/v1/improve',
method: 'POST',
data: { text: plainText }
} )
.then( ( data ) => {
// Pull the generated text out of the confirmed response shape.
let improved = '';
if ( data.candidates && data.candidates[ 0 ] && data.candidates[ 0 ].message ) {
data.candidates[ 0 ].message.parts.forEach( ( part ) => {
if ( part.text ) {
improved += part.text;
}
} );
}
// Replace the block's content with the improved version.
if ( improved ) {
props.setAttributes( { content: improved } );
}
} );
};
// Renders the original block UI, plus a new toolbar button next to it.
return el(
Fragment,
{},
el( BlockEdit, props ),
el(
blockEditor.BlockControls,
{},
el(
components.ToolbarGroup,
{},
el( components.ToolbarButton, {
icon: 'lightbulb',
label: 'Improve with AI',
onClick: improveText
} )
)
)
);
Strip the HTML tags from the block’s content attribute, and you get plain text to send to the AI. The response comes back the same way it always has, data.candidates[0].message.parts.
setAttributes( { content: improved } ) is what actually replaces the block’s text, the same attribute-updating pattern Chapter 22 used, just applied to a block this chapter didn’t register itself.
The el() calls at the end build what you see: the original block (el( BlockEdit, props )), plus a toolbar (BlockControls) containing a button (ToolbarButton) inside a group (ToolbarGroup). Fragment just lets both of those, the block and the toolbar, render side by side without needing an extra wrapping element around them.
Putting it together
Create chapter-23-improve-with-ai.php inside includes:
<?php
/**
* Chapter 23: Add "Improve with AI" to Existing Blocks
* Usage: select any Paragraph block in the editor, look for the new
* toolbar button. No shortcode, no new block, this extends one that
* already exists.
*/
if ( ! defined( 'ABSPATH' ) ) {
exit; // No direct access.
}
// Registers the REST endpoint the block filter's JavaScript will call.
function ai_course_ch23_register_rest_route() {
register_rest_route(
'ai-course/v1',
'/improve',
array(
'methods' => 'POST',
'callback' => 'ai_course_ch23_improve_text',
'permission_callback' => function () {
// Only logged-in users who can edit posts may improve text.
return current_user_can( 'edit_posts' );
},
'args' => array(
'text' => array(
'required' => true,
'type' => 'string',
'sanitize_callback' => 'sanitize_textarea_field',
),
),
)
);
}
add_action( 'rest_api_init', 'ai_course_ch23_register_rest_route' );
// Handles the actual REST request: runs the prompt, returns the result.
function ai_course_ch23_improve_text( WP_REST_Request $request ) {
$text = $request->get_param( 'text' );
$result = wp_ai_client_prompt( "Improve the clarity and flow of this text. Keep the same meaning and roughly the same length. Return only the improved text, nothing else:\n\n{$text}" )
->using_temperature( 0.5 )
->generate_text_result();
// rest_ensure_response() handles both success and WP_Error automatically.
return rest_ensure_response( $result );
}
function ai_course_ch23_enqueue_block_editor_assets() {
wp_enqueue_script(
'ai-course-ch23-improve',
plugins_url( 'js/chapter-23-improve.js', __FILE__ ),
array( 'wp-blocks', 'wp-element', 'wp-hooks', 'wp-compose', 'wp-block-editor', 'wp-components', 'wp-api-fetch' ),
'1.0',
true
);
}
add_action( 'enqueue_block_editor_assets', 'ai_course_ch23_enqueue_block_editor_assets' );
Then create chapter-23-improve.js inside includes/js:
( function ( hooks, compose, blockEditor, components, element, apiFetch ) {
const el = element.createElement;
const { Fragment } = element;
// Wraps every block's edit UI, but only actually changes anything
// for Paragraph blocks that are currently selected.
const withAIImprove = compose.createHigherOrderComponent( ( BlockEdit ) => {
return ( props ) => {
if ( props.name !== 'core/paragraph' || ! props.isSelected ) {
// Every other block passes through untouched.
return el( BlockEdit, props );
}
const improveText = () => {
// The block stores its content as an HTML string. Strip
// tags to get plain text for the prompt.
const plainText = props.attributes.content.replace( /<[^>]*>/g, '' );
apiFetch( {
path: '/ai-course/v1/improve',
method: 'POST',
data: { text: plainText }
} )
.then( ( data ) => {
let improved = '';
if ( data.candidates && data.candidates[ 0 ] && data.candidates[ 0 ].message ) {
data.candidates[ 0 ].message.parts.forEach( ( part ) => {
if ( part.text ) {
improved += part.text;
}
} );
}
// Replace the block's content with the improved version.
if ( improved ) {
props.setAttributes( { content: improved } );
}
} );
};
return el(
Fragment,
{},
el( BlockEdit, props ),
el(
blockEditor.BlockControls,
{},
el(
components.ToolbarGroup,
{},
el( components.ToolbarButton, {
icon: 'lightbulb',
label: 'Improve with AI',
onClick: improveText
} )
)
)
);
};
}, 'withAIImprove' );
hooks.addFilter( 'editor.BlockEdit', 'ai-course/improve-with-ai', withAIImprove );
} )( window.wp.hooks, window.wp.compose, window.wp.blockEditor, window.wp.components, window.wp.element, window.wp.apiFetch );
Write a paragraph, anything, select it, and a new lightbulb icon appears in the toolbar above it. Click it, and after a moment, the paragraph’s text is replaced with an AI-improved version, right in place, no dialog, no new block, the same paragraph just reads differently now.
Worth knowing before you rely on this
Stripping HTML tags before sending the text means any inline formatting, bold, italic, links, gets lost once the AI’s plain-text response replaces the original content. Fine for a quick rewrite of plain prose, not something you’d want on a paragraph with careful inline formatting already in it.
There is a fix, if you need it: send the actual HTML instead of stripping it, and tell the prompt explicitly to preserve every tag while only improving the wording, something like “preserve all HTML tags exactly as they appear, don’t add or remove any formatting.”
One more thing to know. WordPress doesn’t trust dynamically inserted HTML by default. When a post is saved, WordPress strips out dangerous tags like <script>, but only for users who don’t have the unfiltered_html capability. Admins usually have that capability, so this safety check wouldn’t catch anything unexpected the AI sends back.
If you build the HTML-preserving version, add wp_kses_post() yourself before saving the response. Don’t trust the AI’s HTML as-is.
This chapter leaves that part out, just to keep the example simple. Add it yourself once the basic version works.
Try it yourself
Change the prompt in ai_course_ch23_improve_text() to a specific kind of improvement instead of a general one, more concise, more formal, simpler language for a younger audience. The toolbar button and the block filter don’t need to change at all, only the instruction the AI receives does.
For readers who already know JSX
The imports:
import { addFilter } from '@wordpress/hooks';
import { createHigherOrderComponent } from '@wordpress/compose';
import { BlockControls } from '@wordpress/block-editor';
import { ToolbarGroup, ToolbarButton } from '@wordpress/components';
import { Fragment } from '@wordpress/element';
import apiFetch from '@wordpress/api-fetch';
The return statement inside the wrapped component:
return (
<Fragment>
<BlockEdit { ...props } />
<BlockControls>
<ToolbarGroup>
<ToolbarButton
icon="lightbulb"
label="Improve with AI"
onClick={ improveText }
/>
</ToolbarGroup>
</BlockControls>
</Fragment>
);
Everything else, the filter registration, the REST call, the attribute update, stays exactly the same.