Editor UI Extensions

Adding an AI Sidebar Panel

⏱ 18 min

Everything so far lives inside one block. This lesson steps outside any single block and adds a panel to the editor’s own Settings sidebar, next to Status & Visibility, Categories, and Tags, showing AI-suggested tags for the post as a whole. It’s the first lesson in this course that extends the editor’s chrome rather than a block’s own UI.

What you'll learn in this lesson
registerPlugin() and PluginDocumentSettingPanel
Adding a real panel to the document Settings sidebar, not a block's Inspector.
Why this isn't InspectorControls
InspectorControls belongs to a single block's edit() function, this panel is post-level, not block-level.
Reading the post's current content from editor state
useSelect() against the core/editor data store.
Applying a suggestion back to the post
Finding or creating a real tag term and updating the post's tags with useDispatch().
Prerequisites

Lesson 2’s REST endpoint pattern (a route calling wp_ai_client_prompt()) and Lesson 1’s apiFetch auth model. Familiarity with @wordpress/data (useSelect/useDispatch) is helpful, this lesson explains each call it uses.

Step 1: PluginDocumentSettingPanel versus InspectorControls

Both add UI to the same visual sidebar, but they attach to different things. InspectorControls, used inside a block’s edit() function, adds controls scoped to one selected block instance, its own attributes, its own settings. PluginSidebar and PluginDocumentSettingPanel, registered independently of any block via registerPlugin(), add UI scoped to the whole post, visible regardless of which block, if any, is currently selected.

A panel suggesting tags for the entire post belongs with the latter, there’s no single block whose attributes it’s editing, it’s editing the post’s own taxonomy terms. Use PluginDocumentSettingPanel when you want a new section in the existing Settings tab, or PluginSidebar when you want an entirely separate sidebar of its own, reachable from the editor’s top toolbar icons.

Step 2: A REST endpoint that suggests tags from post content

Same shape as Lesson 2’s endpoint, a schema-constrained call to wp_ai_client_prompt(), this time asking for a JSON array of tag suggestions:

// File: my-plugin/my-plugin.php
add_action( 'rest_api_init', function () {
	register_rest_route(
		'my-plugin/v1',
		'/suggest-tags',
		array(
			'methods'             => 'POST',
			'permission_callback' => function () {
				return current_user_can( 'edit_posts' );
			},
			'args'                => array(
				'content' => array( 'type' => 'string', 'required' => true ),
			),
			'callback'            => function ( WP_REST_Request $request ) {
				$content = wp_strip_all_tags( $request->get_param( 'content' ) );

				if ( '' === trim( $content ) ) {
					return array( 'tags' => array() );
				}

				$schema = array(
					'type'       => 'object',
					'properties' => array(
						'tags' => array( 'type' => 'array', 'items' => array( 'type' => 'string' ) ),
					),
					'required'   => array( 'tags' ),
				);

				$raw = wp_ai_client_prompt()
					->with_text( $content )
					->using_system_instruction( 'Suggest 3 to 6 short, relevant blog tags for this post content. Lowercase, no hashtags.' )
					->as_json_response( $schema )
					->generate_text();

				$data = json_decode( $raw, true );

				if ( ! is_array( $data ) || ! isset( $data['tags'] ) ) {
					return new WP_Error( 'ai_suggest_tags_failed', 'Could not parse AI response.', array( 'status' => 500 ) );
				}

				return array( 'tags' => $data['tags'] );
			},
		)
	);
} );

Step 3: The panel itself

registerPlugin() mounts a component that isn’t tied to any block. Inside it, useSelect() reads the post’s current content from the core/editor store, and useDispatch() provides a way to write the chosen tag back onto the post:

// File: my-plugin/src/ai-tags-panel.js
import { registerPlugin } from '@wordpress/plugins';
import { PluginDocumentSettingPanel } from '@wordpress/editor';
import { useSelect, useDispatch } from '@wordpress/data';
import { useState } from '@wordpress/element';
import { Button, Spinner } from '@wordpress/components';
import apiFetch from '@wordpress/api-fetch';
import { __ } from '@wordpress/i18n';

function AiTagsPanel() {
	const [ suggestions, setSuggestions ] = useState( [] );
	const [ isBusy, setIsBusy ] = useState( false );

	const content = useSelect(
		( select ) => select( 'core/editor' ).getEditedPostAttribute( 'content' ),
		[]
	);
	const currentTags = useSelect(
		( select ) => select( 'core/editor' ).getEditedPostAttribute( 'tags' ) || [],
		[]
	);
	const { editPost } = useDispatch( 'core/editor' );

	function fetchSuggestions() {
		setIsBusy( true );
		apiFetch( { path: '/my-plugin/v1/suggest-tags', method: 'POST', data: { content } } )
			.then( ( response ) => setSuggestions( response.tags ) )
			.finally( () => setIsBusy( false ) );
	}

	async function applyTag( name ) {
		const existing = await apiFetch( { path: `/wp/v2/tags?search=${ encodeURIComponent( name ) }` } );
		const match = existing.find( ( t ) => t.name.toLowerCase() === name.toLowerCase() );
		const term = match || ( await apiFetch( { path: '/wp/v2/tags', method: 'POST', data: { name } } ) );

		if ( ! currentTags.includes( term.id ) ) {
			editPost( { tags: [ ...currentTags, term.id ] } );
		}
	}

	return (
		<PluginDocumentSettingPanel name="ai-tags-panel" title={ __( 'AI Suggested Tags' ) }>
			<Button variant="secondary" onClick={ fetchSuggestions } disabled={ isBusy }>
				{ isBusy ? <Spinner /> : __( 'Suggest tags' ) }
			</Button>
			<ul style={ { marginTop: '12px' } }>
				{ suggestions.map( ( tag ) => (
					<li key={ tag }>
						<Button variant="tertiary" onClick={ () => applyTag( tag ) }>
							+ { tag }
						</Button>
					</li>
				) ) }
			</ul>
		</PluginDocumentSettingPanel>
	);
}

registerPlugin( 'my-plugin-ai-tags', { render: AiTagsPanel } );

Enqueue this script the same way as any editor script, with wp_enqueue_script() on enqueue_block_editor_assets, declaring wp-plugins, wp-editor, wp-data, wp-element, wp-components, and wp-api-fetch as dependencies (or let @wordpress/scripts’ generated .asset.php handle that for you automatically).

Creating a new tag term needs the right capability

applyTag()’s POST to /wp/v2/tags creates a brand-new term if no match exists. That endpoint checks the current user’s capability to manage that taxonomy’s terms, not just edit_posts, a Contributor who can edit their own posts may not be able to create new tags. If a suggestion silently fails to apply, check the user’s role before assuming the AI call is at fault.

Test it

Build and enqueue the script, open a post with some real content, open the Settings tab in the editor sidebar, and confirm a new “AI Suggested Tags” panel appears below the built-in ones. Click Suggest tags, wait for the list to populate, then click one of the suggested tags. Open the built-in Tags panel just above it, the tag you clicked should now appear there too, confirming editPost() actually wrote to the post’s real tag list rather than just updating local component state.

getEditedPostAttribute('content') can be stale in the visual editor

For posts edited primarily in the visual (non-code) editor, content reflects the serialized block markup, which only updates on certain state changes, not on every keystroke. If suggestions seem to reflect an older version of the post, that’s normal, it’s rarely stale enough to produce meaningfully wrong suggestions, but don’t expect character-by-character freshness the way a controlled <textarea> would give you.

Recap

PluginDocumentSettingPanel, mounted with registerPlugin(), adds a real section to the editor’s existing Settings sidebar, scoped to the whole post rather than one block, which is why it, not InspectorControls, is the right tool for a post-wide feature like tag suggestions. The panel read the post’s content with useSelect() against core/editor, sent it to a REST endpoint calling wp_ai_client_prompt(), and wrote an accepted suggestion back with useDispatch( 'core/editor' ).editPost(), after finding or creating the actual tag term through the standard /wp/v2/tags REST endpoint.

Resources & further reading

← Building a Custom AI Block Bindings Source Building an AI-Powered Block Toolbar Action →