The previous lesson established the pattern: editor JavaScript, authenticated as the
current user, calling a REST endpoint that runs wp_ai_client_prompt() on the server.
This lesson builds a complete, real block around it, an “AI Text” block with editable
content and a button that rewrites it. It’s deliberately a self-contained custom block
rather than a modification to an existing one, everything you build here becomes the
foundation the rest of this course extends: Block Bindings, a sidebar panel, a toolbar
action, and an inserter variation all build on top of what you register in this lesson.
A local WordPress environment with an AI provider plugin active and a working API key
(Course 5, Lessons 1 and 2, cover this setup in full). Basic familiarity with
registering a custom block via block.json and a build step (@wordpress/scripts) is
assumed, this lesson doesn’t re-teach block scaffolding from zero.
Step 1: Register the block
Start with block.json. The block has two attributes: content, the editable text,
and mode, which a later lesson (Block Variations) will use to pre-configure a
“summarize” variant of this same block:
// File: my-plugin/blocks/ai-text/block.json
{
"$schema": "https://schemas.wp.org/trunk/block.json",
"apiVersion": 3,
"name": "my-plugin/ai-text",
"title": "AI Text",
"category": "text",
"icon": "editor-spellcheck",
"description": "A paragraph with a button that rewrites it using AI.",
"attributes": {
"content": { "type": "string", "source": "html", "selector": "p", "default": "" },
"mode": { "type": "string", "default": "rewrite" }
},
"supports": { "html": false },
"textdomain": "my-plugin",
"editorScript": "file:./index.js"
}
Register it in PHP with register_block_type(), pointing at the directory so
WordPress reads block.json itself:
// File: my-plugin/my-plugin.php
add_action( 'init', function () {
register_block_type( __DIR__ . '/blocks/ai-text' );
} );
Step 2: The REST endpoint
This is the server-side half. It accepts the block’s current text plus an instruction,
and calls wp_ai_client_prompt() exactly the way Course 5 covers in depth:
// File: my-plugin/my-plugin.php
add_action( 'rest_api_init', function () {
register_rest_route(
'my-plugin/v1',
'/rewrite',
array(
'methods' => 'POST',
'permission_callback' => function () {
return current_user_can( 'edit_posts' );
},
'args' => array(
'content' => array( 'type' => 'string', 'required' => true ),
'instruction' => array(
'type' => 'string',
'default' => 'Rewrite the following text to be clearer and more concise, keep it roughly the same length.',
),
),
'callback' => function ( WP_REST_Request $request ) {
$content = $request->get_param( 'content' );
$instruction = $request->get_param( 'instruction' );
if ( '' === trim( wp_strip_all_tags( $content ) ) ) {
return new WP_Error( 'empty_content', 'There is no text to rewrite.', array( 'status' => 400 ) );
}
$result = wp_ai_client_prompt( $instruction . "\n\n" . $content )->generate_text();
if ( is_wp_error( $result ) ) {
return $result;
}
return array( 'rewritten' => wp_kses_post( $result ) );
},
)
);
} );
permission_callback is doing real work here, it’s the only thing standing between
this endpoint and anyone who happens to know the URL. Since this route runs inside an
authenticated wp-admin session (Lesson 1), a straightforward capability check is
correct and sufficient, there’s no need for anything more elaborate than what any other
authenticated REST route would use.
Step 3: The block’s edit() function
Now the client side. edit.js renders the editable text plus a button, calls
apiFetch() on click, and writes the AI’s response back into the block with
setAttributes():
// File: my-plugin/blocks/ai-text/edit.js
import { useState } from '@wordpress/element';
import { useBlockProps, RichText } from '@wordpress/block-editor';
import { Button, Spinner } from '@wordpress/components';
import apiFetch from '@wordpress/api-fetch';
import { __ } from '@wordpress/i18n';
export default function Edit( { attributes, setAttributes } ) {
const { content, mode } = attributes;
const [ isBusy, setIsBusy ] = useState( false );
const [ error, setError ] = useState( null );
const blockProps = useBlockProps();
const label = mode === 'summarize' ? __( 'Summarize with AI' ) : __( 'Rewrite with AI' );
const instruction =
mode === 'summarize'
? 'Summarize the following text in one or two sentences.'
: 'Rewrite the following text to be clearer and more concise, keep it roughly the same length.';
function handleClick() {
setIsBusy( true );
setError( null );
apiFetch( {
path: '/my-plugin/v1/rewrite',
method: 'POST',
data: { content, instruction },
} )
.then( ( response ) => {
setAttributes( { content: response.rewritten } );
} )
.catch( ( err ) => {
setError( err.message || __( 'The AI request failed. Try again.' ) );
} )
.finally( () => setIsBusy( false ) );
}
return (
<div { ...blockProps }>
<RichText
tagName="p"
value={ content }
onChange={ ( value ) => setAttributes( { content: value } ) }
placeholder={ __( 'Write something, then let AI improve it…' ) }
/>
<Button variant="secondary" onClick={ handleClick } disabled={ isBusy || ! content }>
{ isBusy ? <Spinner /> : label }
</Button>
{ error && <p style={ { color: '#cc1818' } }>{ error }</p> }
</div>
);
}
save.js just needs to render the same markup back out statically, since this block
stores its content as an attribute rather than calling PHP to render:
// File: my-plugin/blocks/ai-text/save.js
import { useBlockProps, RichText } from '@wordpress/block-editor';
export default function save( { attributes } ) {
const blockProps = useBlockProps.save();
return (
<div { ...blockProps }>
<RichText.Content tagName="p" value={ attributes.content } />
</div>
);
}
And index.js ties block.json’s registration to your edit/save implementations:
// File: my-plugin/blocks/ai-text/index.js
import { registerBlockType } from '@wordpress/blocks';
import Edit from './edit';
import save from './save';
import metadata from './block.json';
registerBlockType( metadata.name, { edit: Edit, save } );
Test it
Build your assets (npx wp-scripts build), activate the plugin, then in the block
editor insert the “AI Text” block from the inserter, search for “AI Text”. Type a short
paragraph, click Rewrite with AI, and confirm the text changes to a rewritten
version within a few seconds. Check your browser’s Network tab, you should see a POST
request to /wp-json/my-plugin/v1/rewrite returning a 200 status with a rewritten
field in its JSON body.
If the button spins forever or the .catch() fires immediately, check the Network tab
before assuming the AI call itself failed. A missing permission_callback return, a
PHP fatal error inside the callback, or forgetting to register the route on
rest_api_init all produce a REST response apiFetch() can’t parse as success, and
they look identical to a slow AI response from the button’s perspective.
Recap
You built a complete, working AI-powered block: a block.json registration with a
content and mode attribute, a REST endpoint whose callback calls
wp_ai_client_prompt() behind a real permission_callback, and an edit() function
that calls that endpoint with apiFetch() and writes the result back with
setAttributes(). This exact block, its REST endpoint, and its mode attribute are
what the rest of this course builds on: Block Bindings, a sidebar panel, a toolbar
action for other blocks, and an inserter variation using the mode attribute you
already added.
Resources & further reading
- Block API reference: block.json, developer.wordpress.org
- @wordpress/api-fetch package reference, developer.wordpress.org
- register_block_type() function reference, developer.wordpress.org
- Introducing the AI Client in WordPress 7.0, make.wordpress.org