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.
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).
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.
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
- PluginDocumentSettingPanel reference, developer.wordpress.org
- PluginSidebar reference, developer.wordpress.org
- @wordpress/data reference, developer.wordpress.org
- WordPress REST API tags endpoint reference, developer.wordpress.org