Streaming chat feels fast because the browser paints tokens as they arrive. In WordPress, that means a REST route that keeps the connection open, a server-side proxy to OpenAI, and a client that reads the response body incrementally. The hard parts are not the UI. They are authorisation, persistence, rate limiting and output handling. This tutorial builds a small plugin that does all four without trusting the model.
Prerequisites and guardrails
You need WordPress 6.5 or later, PHP 8.1 or later, the cURL extension, HTTPS, and an OpenAI API key. You also need to edit wp-config.php and activate a plugin so the custom tables are created. A Redis object cache is optional, but useful if you later move quota checks into atomic cache operations.
- Do not expose the API key to the browser. All OpenAI calls happen server-side.
- Require a nonce and a logged-in user. The nonce stops cross-site request forgery; the login requirement makes per-user quotas meaningful.
- Treat model output as untrusted text. The client uses
textContent, notinnerHTML. The server sanitises the final assistant message before storage. - Add a kill switch. A constant in
wp-config.phpcan disable the feature without a deployment.
What you are building
The browser sends a POST request to /wp-json/wptuts-ai/v1/chat with an X-WP-Nonce header. The route uses a permission_callback to check the nonce, the kill switch and the user capability. The callback reserves quota, stores the user message, calls OpenAI with cURL, and re-emits the upstream Server-Sent Events as event: delta messages. When the stream ends, the assistant message is sanitised, stored, and the token reservation is adjusted.
The custom conversation table keeps messages out of post meta. The usage table makes request and token limits atomic. The examples use 20 requests and 20,000 tokens per user per day, and 60 requests and 60,000 tokens per IP per day. Tune those numbers for your audience and model.
Step 1 Create the plugin and tables
Start with a plugin that creates two tables on activation. The messages table stores the conversation transcript. The usage table stores daily counters keyed by scope and scope key. The unique key on scope, scope_key, window_start is what makes the quota update safe under concurrency.
<?php
/**
* Plugin Name: WP Tuts AI Chat
* Description: Streaming AI chat with SSE, conversation storage and quotas.
*/
defined('ABSPATH') || exit;
if ( defined('WPTUTS_AI_CHAT_DISABLED') && WPTUTS_AI_CHAT_DISABLED ) {
return;
}
register_activation_hook(__FILE__, 'wptuts_ai_install');
function wptuts_ai_install() {
global $wpdb;
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
$charset = $wpdb->get_charset_collate();
$messages = $wpdb->prefix . 'wptuts_ai_messages';
$usage = $wpdb->prefix . 'wptuts_ai_usage';
$sql = 'CREATE TABLE ' . $messages . ' (
id bigint unsigned NOT NULL AUTO_INCREMENT,
conversation_id char(36) NOT NULL,
user_id bigint unsigned NOT NULL DEFAULT 0,
role varchar(16) NOT NULL,
content longtext NOT NULL,
token_count int unsigned NOT NULL DEFAULT 0,
created_at datetime NOT NULL,
PRIMARY KEY (id),
KEY conversation (conversation_id, id),
KEY user_created (user_id, created_at)
) ' . $charset . ';
CREATE TABLE ' . $usage . ' (
id bigint unsigned NOT NULL AUTO_INCREMENT,
scope varchar(8) NOT NULL,
scope_key varchar(64) NOT NULL,
window_start datetime NOT NULL,
requests int unsigned NOT NULL DEFAULT 0,
tokens int unsigned NOT NULL DEFAULT 0,
PRIMARY KEY (id),
UNIQUE KEY scope_window (scope, scope_key, window_start)
) ' . $charset . ';';
dbDelta($sql);
}
The conversation_id is a UUID generated by WordPress. The role column stores user or assistant. The token_count column is filled from the upstream usage block when available. The usage table uses a daily UTC window; using UTC avoids daylight-saving edge cases.
Step 2 Register the nonce-protected REST route
Use register_rest_route() with a permission_callback. The permission callback runs before the streaming callback, so it is the right place to reject disabled, unauthenticated or nonce-less requests. The nonce action is wp_rest, which is the same nonce WordPress uses for the REST API.
add_action('rest_api_init', function () {
register_rest_route('wptuts-ai/v1', '/chat', [
'methods' => 'POST',
'callback' => 'wptuts_ai_chat_stream',
'permission_callback' => 'wptuts_ai_chat_permissions',
'args' => [
'message' => [
'required' => true,
'type' => 'string',
'sanitize_callback' => 'sanitize_textarea_field',
],
'conversation_id' => [
'required' => false,
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
],
],
]);
});
function wptuts_ai_chat_permissions( WP_REST_Request $request ) {
if ( defined('WPTUTS_AI_CHAT_DISABLED') && WPTUTS_AI_CHAT_DISABLED ) {
return new WP_Error('wptuts_ai_disabled', 'Chat is disabled.', ['status' => 503]);
}
$nonce = $request->get_header('X-WP-Nonce');
if ( ! $nonce || ! wp_verify_nonce($nonce, 'wp_rest') ) {
return new WP_Error('wptuts_ai_nonce', 'Invalid nonce.', ['status' => 403]);
}
if ( ! is_user_logged_in() || ! current_user_can('read') ) {
return new WP_Error('wptuts_ai_auth', 'Login required.', ['status' => 401]);
}
return true;
}
function wptuts_ai_ip_hash() {
$ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : '';
return hash_hmac('sha256', $ip, wp_salt('nonce'));
}
function wptuts_ai_reserve_quota( $user_id, $ip_hash, $max_tokens ) {
global $wpdb;
$table = $wpdb->prefix . 'wptuts_ai_usage';
$window = gmdate('Y-m-d 00:00:00');
$limits = [
'user' => ['requests' => 20, 'tokens' => 20000],
'ip' => ['requests' => 60, 'tokens' => 60000],
];
$scopes = [
'user' => (string) $user_id,
'ip' => $ip_hash,
];
$wpdb->query('START TRANSACTION');
foreach ( $scopes as $scope => $key ) {
$wpdb->query($wpdb->prepare(
'INSERT INTO ' . $table . ' (scope, scope_key, window_start, requests, tokens)
VALUES (%s, %s, %s, 0, 0)
ON DUPLICATE KEY UPDATE id = id',
$scope, $key, $window
));
$row = $wpdb->get_row($wpdb->prepare(
'SELECT requests, tokens FROM ' . $table . '
WHERE scope = %s AND scope_key = %s AND window_start = %s FOR UPDATE',
$scope, $key, $window
));
if ( ! $row || (int) $row->requests + 1 > $limits[$scope]['requests'] || (int) $row->tokens + $max_tokens > $limits[$scope]['tokens'] ) {
$wpdb->query('ROLLBACK');
return new WP_Error('wptuts_ai_quota', 'Rate limit or token budget exceeded.', ['status' => 429]);
}
}
foreach ( $scopes as $scope => $key ) {
$wpdb->query($wpdb->prepare(
'UPDATE ' . $table . ' SET requests = requests + 1, tokens = tokens + %d
WHERE scope = %s AND scope_key = %s AND window_start = %s',
$max_tokens, $scope, $key, $window
));
}
$wpdb->query('COMMIT');
return true;
}
The quota function uses a transaction and SELECT ... FOR UPDATE so two concurrent requests cannot both pass the same limit. It reserves the maximum token count before the upstream call, then wptuts_ai_adjust_tokens() corrects the reservation when the real usage arrives. If you run on a platform where transactions are unreliable, move the counters to Redis with atomic increments and a fixed window.
Step 3 Stream SSE from OpenAI to the browser
WordPress HTTP API helpers buffer the response, which defeats streaming. Use cURL with CURLOPT_WRITEFUNCTION to forward chunks as they arrive. The server sends its own SSE frames to the browser, so the client never sees the OpenAI key or the upstream response shape.
function wptuts_ai_chat_stream( WP_REST_Request $request ) {
if ( defined('WPTUTS_AI_CHAT_DISABLED') && WPTUTS_AI_CHAT_DISABLED ) {
return new WP_Error('wptuts_ai_disabled', 'Chat is disabled.', ['status' => 503]);
}
$user_id = get_current_user_id();
$ip_hash = wptuts_ai_ip_hash();
$message = $request->get_param('message');
$message = is_string($message) ? trim($message) : '';
if ( '' === $message || mb_strlen($message) > 2000 ) {
return new WP_Error('wptuts_ai_message', 'Message must be 1-2000 characters.', ['status' => 400]);
}
$conversation_id = $request->get_param('conversation_id');
if ( ! $conversation_id || ! wp_is_uuid($conversation_id) ) {
$conversation_id = wp_generate_uuid4();
}
$max_tokens = 512;
$quota = wptuts_ai_reserve_quota($user_id, $ip_hash, $max_tokens);
if ( is_wp_error($quota) ) {
return $quota;
}
wptuts_ai_store_message($conversation_id, $user_id, 'user', $message, 0);
$history = wptuts_ai_get_history($conversation_id, $user_id, 10);
$messages = array_merge(
[['role' => 'system', 'content' => 'You are a concise WordPress support assistant. Never output HTML.']],
$history
);
wptuts_ai_sse_headers();
wptuts_ai_sse_event('meta', ['conversation_id' => $conversation_id]);
wptuts_ai_stream_openai($messages, $conversation_id, $user_id, $max_tokens, $max_tokens);
exit;
}
function wptuts_ai_sse_headers() {
if ( function_exists('apache_setenv') ) {
@apache_setenv('no-gzip', '1');
}
@ini_set('zlib.output_compression', '0');
@ini_set('output_buffering', '0');
while ( ob_get_level() > 0 ) {
@ob_end_flush();
}
header('Content-Type: text/event-stream; charset=utf-8');
header('Cache-Control: no-cache, no-store, must-revalidate');
header('X-Accel-Buffering: no');
header('Connection: keep-alive');
status_header(200);
}
function wptuts_ai_sse_event( $event, array $data ) {
echo 'event: ' . $event . chr(10);
echo 'data: ' . wp_json_encode($data) . chr(10) . chr(10);
@ob_flush();
flush();
}
Disable compression and output buffering before sending headers. The X-Accel-Buffering: no header is important on Nginx. The meta event gives the client the conversation ID. The delta event carries text. The done event carries the final token count. If cURL fails, send an error event and still close the stream cleanly.
function wptuts_ai_stream_openai( array $messages, $conversation_id, $user_id, $max_tokens, $reserved ) {
$api_key = defined('WPTUTS_OPENAI_API_KEY') ? WPTUTS_OPENAI_API_KEY : '';
if ( ! $api_key ) {
wptuts_ai_sse_event('error', ['message' => 'Missing API key.']);
return;
}
$payload = wp_json_encode([
'model' => 'gpt-4o-mini',
'messages' => $messages,
'max_tokens' => $max_tokens,
'stream' => true,
'stream_options' => ['include_usage' => true],
]);
$buffer = '';
$assistant = '';
$used = 0;
$ch = curl_init('https://api.openai.com/v1/chat/completions');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $api_key,
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => $payload,
CURLOPT_RETURNTRANSFER => false,
CURLOPT_TIMEOUT => 60,
CURLOPT_WRITEFUNCTION => function ( $ch, $chunk ) use ( &$buffer, &$assistant, &$used ) {
$buffer .= $chunk;
while ( false !== ( $pos = strpos($buffer, chr(10)) ) ) {
$line = trim(substr($buffer, 0, $pos));
$buffer = substr($buffer, $pos + 1);
if ( '' === $line || 0 !== strpos($line, 'data: ') ) {
continue;
}
$data = substr($line, 6);
if ( '[DONE]' === $data ) {
continue;
}
$json = json_decode($data, true);
if ( isset($json['choices'][0]['delta']['content']) ) {
$delta = $json['choices'][0]['delta']['content'];
$assistant .= $delta;
wptuts_ai_sse_event('delta', ['text' => $delta]);
}
if ( isset($json['usage']['total_tokens']) ) {
$used = (int) $json['usage']['total_tokens'];
}
}
return strlen($chunk);
},
]);
curl_exec($ch);
$errno = curl_errno($ch);
curl_close($ch);
if ( $errno ) {
wptuts_ai_sse_event('error', ['message' => 'Upstream request failed.']);
}
if ( $used < 1 ) {
$used = $reserved;
}
$assistant = sanitize_textarea_field($assistant);
wptuts_ai_store_message($conversation_id, $user_id, 'assistant', $assistant, $used);
wptuts_ai_adjust_tokens($user_id, wptuts_ai_ip_hash(), $reserved, $used);
wptuts_ai_sse_event('done', ['conversation_id' => $conversation_id, 'tokens' => $used]);
}
function wptuts_ai_store_message( $conversation_id, $user_id, $role, $content, $tokens = 0 ) {
global $wpdb;
$wpdb->insert($wpdb->prefix . 'wptuts_ai_messages', [
'conversation_id' => $conversation_id,
'user_id' => $user_id,
'role' => $role,
'content' => $content,
'token_count' => $tokens,
'created_at' => gmdate('Y-m-d H:i:s'),
], ['%s', '%d', '%s', '%s', '%d', '%s']);
}
function wptuts_ai_get_history( $conversation_id, $user_id, $limit = 10 ) {
global $wpdb;
$rows = $wpdb->get_results($wpdb->prepare(
'SELECT role, content FROM ' . $wpdb->prefix . 'wptuts_ai_messages
WHERE conversation_id = %s AND user_id = %d
ORDER BY id DESC LIMIT %d',
$conversation_id, $user_id, $limit
));
$rows = array_reverse($rows);
return array_map(function ( $row ) {
return ['role' => $row->role, 'content' => $row->content];
}, $rows);
}
function wptuts_ai_adjust_tokens( $user_id, $ip_hash, $reserved, $actual ) {
global $wpdb;
$table = $wpdb->prefix . 'wptuts_ai_usage';
$window = gmdate('Y-m-d 00:00:00');
$delta = (int) $actual - (int) $reserved;
if ( 0 === $delta ) {
return;
}
foreach ( ['user' => (string) $user_id, 'ip' => $ip_hash] as $scope => $key ) {
$wpdb->query($wpdb->prepare(
'UPDATE ' . $table . ' SET tokens = GREATEST(0, tokens + %d)
WHERE scope = %s AND scope_key = %s AND window_start = %s',
$delta, $scope, $key, $window
));
}
}
The assistant text is sanitised with sanitize_textarea_field() before storage. That preserves line breaks but strips tags. If you later render Markdown to HTML, run the final HTML through wp_kses_post() with a strict allowlist. Do not stream raw HTML to the browser and do not assign model output to innerHTML.
Step 4 Read SSE in the browser without innerHTML
Localise the endpoint and nonce with wp_localize_script(). The client below uses fetch(), a ReadableStream reader and TextDecoder. It splits the buffer on a blank line, parses the SSE fields, and appends text with textContent. That is the key defence: the browser treats the model output as text, not markup.
add_action('wp_enqueue_scripts', function () {
wp_enqueue_script('wptuts-ai-chat', plugins_url('chat.js', __FILE__), [], '1.0', true);
wp_localize_script('wptuts-ai-chat', 'wptutsAi', [
'endpoint' => rest_url('wptuts-ai/v1/chat'),
'nonce' => wp_create_nonce('wp_rest'),
]);
});
const input = document.getElementById('wptuts-ai-input');
const button = document.getElementById('wptuts-ai-send');
const output = document.getElementById('wptuts-ai-output');
const newline = String.fromCharCode(10);
let conversationId = '';
button.addEventListener('click', async () => {
const message = input.value.trim();
if (!message) return;
output.textContent = '';
button.disabled = true;
try {
const response = await fetch(wptutsAi.endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': wptutsAi.nonce,
},
body: JSON.stringify({ message, conversation_id: conversationId }),
});
if (!response.ok || !response.body) {
throw new Error('HTTP ' + response.status);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const parts = buffer.split(newline + newline);
buffer = parts.pop();
for (const part of parts) {
const lines = part.split(newline);
let event = 'message';
let data = '';
for (const line of lines) {
if (line.startsWith('event: ')) event = line.slice(7);
if (line.startsWith('data: ')) data += line.slice(6);
}
if (!data) continue;
const payload = JSON.parse(data);
if (event === 'meta') conversationId = payload.conversation_id;
if (event === 'delta') output.textContent += payload.text;
if (event === 'error') output.textContent += newline + payload.message;
}
}
} catch (error) {
output.textContent += newline + 'Request failed. Please try again.';
} finally {
button.disabled = false;
}
});
The client keeps the conversation ID in memory. For a real interface, store it in a hidden field or in sessionStorage and add a reset button. If you need to cancel a stream, create an AbortController and call abort() on the fetch signal. The server should also check connection_aborted() inside the cURL write callback if you want to stop upstream work early.
Step 5 Add the kill switch and secrets
Put the API key and the kill switch in wp-config.php, above the wp-settings.php include. The plugin checks the constant at load time and again in the permission callback. That gives you two layers: the plugin does not register the route when disabled, and any already-registered route still rejects requests.
define('WPTUTS_OPENAI_API_KEY', 'sk-your-key');
define('WPTUTS_AI_CHAT_DISABLED', true);
Rotate the key if it is ever committed. Better still, read the key from an environment variable and define the constant from that value. The kill switch is deliberately simple: set it to true, save, and the chat stops. Remove it or set it to false to re-enable.
Troubleshooting
- Everything arrives at once. Check gzip,
mod_deflate, PHPoutput_buffering, FastCGI buffering and any CDN. The response must not be buffered. Test withcurl -Nagainst the endpoint. - 403 invalid nonce. The nonce is tied to the user session and expires. Refresh the page. Confirm the
X-WP-Nonceheader is present and the action iswp_rest. - 401 login required. The route intentionally requires a logged-in user. If you need anonymous chat, add a separate route with stricter IP limits and a different nonce strategy.
- 429 quota exceeded. Inspect the usage table for the current UTC window. Delete the test row only on a staging site. In production, raise limits or add a paid tier.
- Tables missing. Activation hooks run on activation, not on every request. Deactivate and reactivate the plugin, or run the install function from WP-CLI.
- cURL errors. Confirm
php-curlis enabled and outbound HTTPS is allowed. Some hosts blockapi.openai.com. - IP limits are wrong behind a proxy. Do not trust
X-Forwarded-Forblindly. Configure your proxy to set a trusted header and read only that header after validating the proxy IP.
Next steps
Add a conversation list endpoint with an ownership check before returning rows. Add a delete endpoint that removes messages by conversation_id and user_id. Add moderation before storage if the assistant is public. Move quota counters to Redis if you expect bursts. Add an AbortController and a stop button. Finally, wrap the UI in a block pattern or shortcode so editors can place it without touching theme templates.
The important architectural point is that streaming is a transport detail. Authorisation, quota enforcement, sanitisation and storage are the parts that keep the feature safe on a real site.
Leave a Reply