What you are building
This tutorial wires a Rewrite selection button into the Gutenberg editor sidebar. It reads the selected block’s text attribute, sends it to a custom REST route on your own site, and that route proxies the request to OpenAI or Anthropic with stream: true. Tokens arrive back in the browser through a ReadableStream, render progressively in the sidebar, can be cancelled mid-flight with an AbortController, and are written back into the block with updateBlockAttributes() only after a capability and nonce check.
The API key never reaches the browser. The editor never talks to the provider directly. Everything crosses one authenticated same-origin boundary you control.
- Server side: a REST route with a real
permission_callback, a per-user throttle, and a cURL write callback that flushes provider SSE frames straight through. - Client side:
registerPlugin()plusPluginSidebar,useSelectfor the selected block,useDispatchfor the write-back. - Failure handling: abort, HTTP errors, partial SSE frames, and blocks that have no obvious text attribute.
Prerequisites
- WordPress 6.5 or newer and PHP 8.1+ (the code uses arrow functions and named arguments are avoided for portability).
- Node 18+ and npm, with
@wordpress/scriptsfor the build. - cURL with streaming support enabled on the host — almost all shared hosts have it, but check
curl_version()if you see an empty response. - An OpenAI or Anthropic key stored in
wp-config.phpor an environment variable, not in the database and never in a JavaScript file. - Familiarity with registering REST routes and the core/block-editor data store.
Architecture: three trust boundaries
- Browser to your REST route. Authenticated by the WordPress cookie plus the
X-WP-Nonceheader. The nonce is not a secret; it binds the request to the current session and blocks CSRF. The capability check is the actual gate. - Your route to the provider. The key lives in PHP only. The route shapes the prompt, caps input length, and throttles per user.
- Provider back to the browser. Raw SSE frames are relayed unchanged, so the client parser stays small and you avoid re-encoding JSON per token.
Do not solve this by fetching the provider from the editor with a key in
window. Any user who can open the editor can read that key, and any XSS on the admin becomes a billing incident.
Step 1 — Scaffold the plugin and register the route
mkdir wp-content/plugins/ai-rewrite-sidebar && cd $_
npm init -y
npm install --save-dev @wordpress/scripts
mkdir src build
# package.json: "scripts": { "build": "wp-scripts build", "start": "wp-scripts start" }
# wp-scripts defaults to src/index.js -> build/index.js
Now the PHP entry point. The permission_callback runs before the callback and before argument sanitisation is trusted, so it is the right place for both the capability check and the throttle.
<?php
/**
* Plugin Name: AI Rewrite Sidebar
* Requires at least: 6.5
* Requires PHP: 8.1
*/
defined( 'ABSPATH' ) || exit;
add_action( 'rest_api_init', function () {
register_rest_route( 'ai-rewrite/v1', '/stream', array(
'methods' => WP_REST_Server::CREATABLE,
'callback' => 'airw_stream_rewrite',
'permission_callback' => 'airw_can_rewrite',
'args' => array(
'text' => array(
'required' => true,
'type' => 'string',
'sanitize_callback' => 'wp_strip_all_tags',
'validate_callback' => static fn( $value ) => strlen( $value ) <= 8000,
),
'instruction' => array(
'type' => 'string',
'default' => 'Rewrite for clarity and concision. Keep the meaning.',
),
),
) );
} );
function airw_can_rewrite( WP_REST_Request $request ) {
if ( ! current_user_can( 'edit_posts' ) ) {
return new WP_Error( 'airw_forbidden', 'Insufficient capability.', array( 'status' => 403 ) );
}
$key = 'airw_rl_' . get_current_user_id();
$count = (int) get_transient( $key );
if ( $count >= 20 ) {
return new WP_Error( 'airw_throttled', 'Too many rewrites. Wait a minute.', array( 'status' => 429 ) );
}
set_transient( $key, $count + 1, MINUTE_IN_SECONDS );
return true;
}
Two details matter here. wp_strip_all_tags keeps the prompt clean and prevents a block full of markup from inflating your token bill. The transient throttle is per user, not per IP, because editors behind a shared office NAT would otherwise starve each other.
Step 2 — Stream from the provider without leaking the key
The callback does not return a WP_REST_Response. It writes headers, flushes, relays the provider’s SSE frames, then exits. That bypasses REST serialisation deliberately — you cannot buffer a stream and also stream it.
function airw_stream_rewrite( WP_REST_Request $request ) {
$api_key = defined( 'AIRW_API_KEY' ) ? AIRW_API_KEY : '';
if ( ! $api_key ) {
return new WP_Error( 'airw_no_key', 'API key missing.', array( 'status' => 500 ) );
}
$body = array(
'model' => 'gpt-4o-mini',
'stream' => true,
'temperature' => 0.4,
'messages' => array(
array( 'role' => 'system', 'content' => 'You are a copy editor. Return only the rewritten text.' ),
array( 'role' => 'user', 'content' => $request['instruction'] . "\n\n" . $request['text'] ),
),
);
nocache_headers();
header( 'Content-Type: text/event-stream; charset=utf-8' );
header( 'Cache-Control: no-cache, no-store' );
header( 'X-Accel-Buffering: no' ); // nginx: do not buffer this response
while ( ob_get_level() > 0 ) {
ob_end_flush();
}
ignore_user_abort( false );
$ch = curl_init( 'https://api.openai.com/v1/chat/completions' );
curl_setopt_array( $ch, array(
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => false,
CURLOPT_TIMEOUT => 90,
CURLOPT_HTTPHEADER => array(
'Content-Type: application/json',
'Authorization: Bearer ' . $api_key,
),
CURLOPT_POSTFIELDS => wp_json_encode( $body ),
CURLOPT_WRITEFUNCTION => static function ( $handle, $chunk ) {
if ( connection_aborted() ) {
return 0; // returning less than strlen() aborts the upstream transfer
}
echo $chunk;
flush();
return strlen( $chunk );
},
) );
curl_exec( $ch );
curl_close( $ch );
exit;
}
Returning 0 from CURLOPT_WRITEFUNCTION is the documented way to abort a transfer. Combined with ignore_user_abort(false), a cancelled browser request stops the upstream call instead of quietly finishing it and billing you. Some hosts buffer at the FastCGI layer, so the abort can lag by a second or two — acceptable, but worth knowing when you test.
Step 3 — Enqueue the sidebar script and hand it a nonce
wp_localize_script is the simplest way to pass the REST URL and a fresh nonce. The nonce is generated at enqueue time, which is after the user is known, so it is valid for the session.
add_action( 'enqueue_block_editor_assets', function () {
$dir = plugin_dir_path( __FILE__ );
$meta = require $dir . 'build/index.asset.php';
wp_enqueue_script(
'airw-sidebar',
plugins_url( 'build/index.js', __FILE__ ),
$meta['dependencies'],
$meta['version'],
true
);
wp_localize_script( 'airw-sidebar', 'airwData', array(
'endpoint' => esc_url_raw( rest_url( 'ai-rewrite/v1/stream' ) ),
'nonce' => wp_create_nonce( 'wp_rest' ),
) );
} );
You are using raw fetch() rather than apiFetch because apiFetch resolves a full JSON body — it cannot give you a reader. That means you attach the nonce yourself. See the nonce documentation for why the header name must be exactly X-WP-Nonce.
Step 4 — Register the PluginSidebar and read the selected block
Keep the attribute lookup defensive. Paragraphs, headings, quotes and preformatted blocks all expose content, but third-party blocks vary, and a rewrite button that silently does nothing is worse than one that explains itself.
import { registerPlugin } from '@wordpress/plugins';
import { PluginSidebar, PluginSidebarMoreMenuItem } from '@wordpress/editor';
import { useSelect, useDispatch } from '@wordpress/data';
import { useState, useRef, useCallback } from '@wordpress/element';
import { Button, TextareaControl, Notice, PanelBody } from '@wordpress/components';
import { __ } from '@wordpress/i18n';
const TEXT_ATTRS = [ 'content', 'value', 'caption' ];
function pickTextAttribute( block ) {
if ( ! block ) return null;
for ( const attr of TEXT_ATTRS ) {
if ( typeof block.attributes[ attr ] === 'string' ) return attr;
}
return null;
}
function RewritePanel() {
const [ instruction, setInstruction ] = useState( 'Rewrite for clarity, keep the meaning.' );
const [ output, setOutput ] = useState( '' );
const [ error, setError ] = useState( '' );
const [ busy, setBusy ] = useState( false );
const controller = useRef( null );
const block = useSelect( ( select ) => select( 'core/block-editor' ).getSelectedBlock(), [] );
const { updateBlockAttributes } = useDispatch( 'core/block-editor' );
const attr = pickTextAttribute( block );
// ... run(), apply() and the JSX below
}
registerPlugin( 'airw-rewrite', {
render: () => (
<>
<PluginSidebarMoreMenuItem target="airw-rewrite">{ __( 'AI Rewrite', 'airw' ) }</PluginSidebarMoreMenuItem>
<PluginSidebar name="airw-rewrite" title={ __( 'AI Rewrite', 'airw' ) }>
<RewritePanel />
</PluginSidebar>
</>
),
} );
getSelectedBlock() returns a fresh object whenever the block changes, so the panel re-renders on selection change without any subscription code. If you need the block type’s declared attributes for a stricter check, wp.blocks.getBlockType( block.name ).attributes gives you the schema.
Step 5 — Stream tokens into state with AbortController
The parser is deliberately boring: accumulate decoded text, split on blank lines, keep the trailing partial frame in the buffer, and ignore anything that is not a data: line. Provider keep-alive comments and [DONE] sentinels fall through harmlessly.
const run = useCallback( async () => {
if ( ! block || ! attr ) {
setError( __( 'Select a text block first.', 'airw' ) );
return;
}
controller.current = new AbortController();
setBusy( true );
setError( '' );
setOutput( '' );
try {
const response = await fetch( window.airwData.endpoint, {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': window.airwData.nonce,
},
body: JSON.stringify( { text: block.attributes[ attr ], instruction } ),
signal: controller.current.signal,
} );
if ( ! response.ok || ! response.body ) {
throw new Error( `Request failed: HTTP ${ response.status }` );
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let text = '';
while ( true ) {
const { value, done } = await reader.read();
if ( done ) break;
buffer += decoder.decode( value, { stream: true } );
const frames = buffer.split( '\n\n' );
buffer = frames.pop() ?? '';
for ( const frame of frames ) {
const line = frame.trim();
if ( ! line.startsWith( 'data:' ) ) continue;
const payload = line.slice( 5 ).trim();
if ( payload === '[DONE]' ) continue;
try {
const json = JSON.parse( payload );
const delta = json.choices?.[ 0 ]?.delta?.content ?? '';
if ( delta ) {
text += delta;
setOutput( text );
}
} catch ( parseError ) {
// Partial frame or keep-alive comment; ignore.
}
}
}
} catch ( err ) {
if ( err.name !== 'AbortError' ) setError( err.message );
} finally {
setBusy( false );
controller.current = null;
}
}, [ block, attr, instruction ] );
Calling setOutput on every token is fine for a paragraph. For long passages it causes visible jank, because each token triggers a React render. Batch it: write into a useRef string and flush to state on a requestAnimationFrame or a 60–80 ms timer. The abort path stays identical — controller.current.abort() rejects the pending reader.read() with a DOMException named AbortError, which the catch ignores.
Wire the cancel button to disabled={ ! busy } and onClick={ () => controller.current?.abort() }. Do not null the ref in the click handler; let finally do it so a double-click cannot throw.
Step 6 — Write the result back behind the capability check
The write-back is a single dispatch. WordPress applies its own edit_post capability and content sanitisation when the post is saved, so the client-side call is not a security boundary — the server already enforced edit_posts before a single token was generated.
const apply = () => {
if ( ! block || ! attr || ! output.trim() ) return;
updateBlockAttributes( block.clientId, { [ attr ]: output.trim() } );
setOutput( '' );
};
For a paragraph, content is an HTML string; plain text is valid HTML and will be escaped correctly on render. If you want to preserve inline formatting from the original block, pass the model the plain text and re-apply formatting manually — do not ask the model to emit HTML and inject it with innerHTML. If you must accept markup, run it through wp.kses_post on the server before returning it, and keep the client writing only to known attributes.
Hardening checklist
- Key storage:
define( 'AIRW_API_KEY', getenv( 'AIRW_API_KEY' ) );inwp-config.php, or a server env var. Never inwp_optionsunless the option is registered withshow_in_rest => falseand you accept the risk. - Capability:
edit_postsis the minimum. Useedit_postwith the specific post ID if you want per-post control. - Nonce: generated per session, sent as
X-WP-Nonce, never cached in a page cache. - Input cap: the
validate_callbackrejects anything over 8,000 characters before the provider sees it. - Throttle: transient counter per user, reset every minute.
- Logging: log route errors with
error_log(), never log the prompt body or the key. - Output: plain text by default;
wp_kses_postonly if you deliberately accept HTML.
Troubleshooting
401 rest_cookie_invalid_nonce
The nonce is stale or was generated in a cached context. Confirm wp_localize_script runs on enqueue_block_editor_assets (not init), that the header is spelled X-WP-Nonce, and that the fetch uses credentials: 'same-origin'. If the site sits behind a full-page cache that also caches /wp-json/, exclude the route.
Tokens arrive in one lump at the end
Something is buffering. Work through the layers: while ( ob_get_level() ) ob_end_flush(); in PHP, header( 'X-Accel-Buffering: no' ) for nginx, SetEnv no-gzip 1 or a mod_deflate exclusion for Apache, and a Cloudflare rule that bypasses the route. Also check zlib.output_compression in php.ini — it defeats flush() entirely.
Cancel does not stop the upstream request
Verify ignore_user_abort( false ) and the connection_aborted() guard inside CURLOPT_WRITEFUNCTION. On FastCGI hosts the abort signal can be delayed until the next write, so a very slow model may bill a few extra tokens. Set CURLOPT_TIMEOUT to something sane (60–90 s) as a backstop.
Nothing is written back to the block
Log block.name and Object.keys( block.attributes ). The attribute may be content, value, or nested inside a citation object. For core/quote, the inner paragraph is a child block — target the child’s clientId instead of the parent.
Editor freezes while streaming
You are re-rendering the whole sidebar per token. Batch state updates, memoise the output panel, and avoid rendering the full accumulated string in a controlled TextareaControl during the stream — show a read-only preview and swap to the textarea when the stream ends.
Next steps
Once the sidebar works, extend it in directions the block editor already supports: store prompt presets as a sidebar panel with PanelBody sections, register a Query Loop variation that rewrites excerpts in bulk, or expose the same route to a block binding so a post meta field can hold the rewritten copy. Keep the route as the only place that knows the provider, and swapping OpenAI for Anthropic becomes a single function change rather than a client rewrite.
Leave a Reply