Foundations

Building Your First AI-Powered Block

⏱ 22 min

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.

What you'll learn in this lesson
Registering a real block with block.json
Attributes, editor script, and render behavior for a block with editable text.
A REST endpoint that calls wp_ai_client_prompt()
The server side: a route, a permission_callback, and a real AI call.
Calling that endpoint from the block's edit() function
apiFetch, a loading state, and writing the result back with setAttributes.
Handling a slow or failed request in the UI
Disabling the button while a request is in flight and showing a real error.
Prerequisites

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 } );
What happens on a single click
1
1. Button click
handleClick() fires, isBusy is set, disabling the button and showing a spinner.
2
2. apiFetch() call
A POST request to /my-plugin/v1/rewrite, cookie and nonce auth handled automatically.
3
3. Server-side generation
The REST callback runs wp_ai_client_prompt() and returns the rewritten text.
4
4. setAttributes()
The block's content attribute updates, React re-renders the RichText with the new text.

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.

A stuck spinner usually means a JSON parsing or CORS-shaped error, not an AI failure

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

← How AI Fits Into the Block Editor Using the Block Bindings API →