Most "add AI to WordPress" tutorials stop at a plugin settings page with an API key field and a button that sometimes works. The client sites I maintain at UMM Digital needed something that survives shared hosting timeouts, retries on rate limits, streams output to editors and never bills more than the agreed budget. Here is the PHP-native approach that has run in production since spring.
A resilient LLM client in plain PHP
WordPress ships wp_remote_post(), and for simple calls it is fine. For LLM calls you want three things it does not give you out of the box: exponential back-off on 429 and 5xx, a hard timeout that fits your host, and a consistent interface across providers. The class below targets the Anthropic Messages API, and the OpenAI variant differs only in the URL and body shape.
<?php
// includes/class-llm-client.php
declare(strict_types=1);
final class LLM_Client {
private const ENDPOINT = 'https://api.anthropic.com/v1/messages';
private const MODEL = 'claude-sonnet-4-5';
public function __construct(
private readonly string $api_key,
private readonly int $timeout = 45,
private readonly int $max_retries = 3,
) {}
/** @param array<int, array{role:string, content:string}> $messages */
public function complete(string $system, array $messages, int $max_tokens = 800): string {
$body = [
'model' => self::MODEL,
'max_tokens' => $max_tokens,
'system' => $system,
'messages' => $messages,
];
for ($attempt = 0; $attempt <= $this->max_retries; $attempt++) {
$response = wp_remote_post(self::ENDPOINT, [
'timeout' => $this->timeout,
'headers' => [
'content-type' => 'application/json',
'x-api-key' => $this->api_key,
'anthropic-version' => '2023-06-01',
],
'body' => wp_json_encode($body),
]);
if (is_wp_error($response)) {
$this->backoff($attempt);
continue;
}
$status = wp_remote_retrieve_response_code($response);
$json = json_decode(wp_remote_retrieve_body($response), true);
if ($status === 200) {
$this->record_usage($json['usage'] ?? []);
return $json['content'][0]['text'] ?? '';
}
if (in_array($status, [429, 500, 502, 503, 529], true)) {
$this->backoff($attempt, $response);
continue;
}
throw new RuntimeException("LLM error {$status}: " . ($json['error']['message'] ?? 'unknown'));
}
throw new RuntimeException('LLM request failed after retries');
}
private function backoff(int $attempt, $response = null): void {
$retry_after = $response ? (int) wp_remote_retrieve_header($response, 'retry-after') : 0;
$seconds = $retry_after ?: min(2 ** $attempt + random_int(0, 1000) / 1000, 20);
usleep((int) ($seconds * 1_000_000));
}
private function record_usage(array $usage): void {
$key = 'llm_tokens_' . gmdate('Y-m');
$total = (int) get_option($key, 0) + (int) ($usage['input_tokens'] ?? 0) + (int) ($usage['output_tokens'] ?? 0);
update_option($key, $total, false); // autoload = false, it changes constantly
}
}
Note the random_int jitter in the back-off. Without it, every WP-Cron job on a busy multisite retries at the same instant and you get a second rate-limit wave. This is the same pattern I use in Node.js queues, which I describe in Event-Driven Architecture with Redis and BullMQ.
Rule one: never call the model during a page render
A 3 to 8 second API call inside the_content filter will time out on most shared hosts (30 s PHP limit, often less at the proxy) and will make every uncached visitor wait. The correct shape is: enqueue a background job when content changes, store the result in post meta, render from meta.
<?php
// Enqueue on save. Action Scheduler (bundled with WooCommerce, or standalone) beats raw WP-Cron
// because it runs jobs sequentially with logging and retries.
add_action('save_post_post', function (int $post_id, WP_Post $post, bool $update): void {
if (wp_is_post_revision($post_id) || $post->post_status !== 'publish') return;
if (LLM_Budget::exhausted()) return; // hard cap, see below
$hash = md5($post->post_content);
if (get_post_meta($post_id, '_ai_summary_hash', true) === $hash) return; // unchanged
as_enqueue_async_action('umm_generate_summary', ['post_id' => $post_id, 'hash' => $hash], 'umm-ai');
}, 10, 3);
add_action('umm_generate_summary', function (int $post_id, string $hash): void {
$post = get_post($post_id);
if (!$post) return;
$client = new LLM_Client(defined('UMM_LLM_KEY') ? UMM_LLM_KEY : '');
$summary = $client->complete(
LLM_Prompts::SUMMARY_SYSTEM_V2,
[['role' => 'user', 'content' => wp_strip_all_tags($post->post_content)]],
300
);
update_post_meta($post_id, '_ai_summary', sanitize_textarea_field($summary));
update_post_meta($post_id, '_ai_summary_hash', $hash);
update_post_meta($post_id, '_ai_summary_prompt_version', 'v2');
}, 10, 2);
// Render from meta: zero latency, cache-friendly.
add_filter('the_content', function (string $content): string {
if (!is_singular('post')) return $content;
$summary = get_post_meta(get_the_ID(), '_ai_summary', true);
if (!$summary) return $content;
return '<aside class="ai-summary"><strong>TL;DR:</strong> ' . esc_html($summary) . '</aside>' . $content;
});
The API key lives in wp-config.php as a constant, not in the options table. That keeps it out of database exports, out of the admin UI, and out of any plugin that dumps wp_options for "debugging".
Rule two: a spend cap in code
Provider dashboards let you set alerts. Alerts email you after the money is gone. Put the ceiling in the request path:
<?php
final class LLM_Budget {
private const MONTHLY_TOKEN_CAP = 4_000_000; // ~ $12 on Sonnet-class pricing; tune per client
public static function exhausted(): bool {
$used = (int) get_option('llm_tokens_' . gmdate('Y-m'), 0);
if ($used >= self::MONTHLY_TOKEN_CAP) {
if (!get_transient('llm_budget_alerted')) {
wp_mail(get_option('admin_email'), 'AI budget reached', "Used {$used} tokens this month; AI jobs paused.");
set_transient('llm_budget_alerted', 1, DAY_IN_SECONDS);
}
return true;
}
return false;
}
}
Streaming tokens to the editor from PHP
For an interactive "improve this paragraph" button in the block editor, waiting six seconds for the full response feels broken. PHP can stream. Register an admin-ajax or REST endpoint that proxies the provider's SSE stream and flushes as data arrives:
<?php
add_action('rest_api_init', function () {
register_rest_route('umm/v1', '/rewrite', [
'methods' => 'POST',
'permission_callback' => fn() => current_user_can('edit_posts'),
'callback' => 'umm_stream_rewrite',
'args' => ['text' => ['required' => true, 'sanitize_callback' => 'sanitize_textarea_field']],
]);
});
function umm_stream_rewrite(WP_REST_Request $req) {
if (LLM_Budget::exhausted()) return new WP_Error('budget', 'AI budget exhausted', ['status' => 429]);
// Escape WordPress' output buffering and send SSE headers ourselves.
while (ob_get_level()) ob_end_clean();
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache');
header('X-Accel-Buffering: no'); // tell Nginx not to buffer
$ch = curl_init('https://api.anthropic.com/v1/messages');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => ['content-type: application/json', 'x-api-key: ' . UMM_LLM_KEY, 'anthropic-version: 2023-06-01'],
CURLOPT_POSTFIELDS => wp_json_encode([
'model' => 'claude-sonnet-4-5', 'max_tokens' => 600, 'stream' => true,
'system' => LLM_Prompts::REWRITE_SYSTEM_V1,
'messages' => [['role' => 'user', 'content' => $req['text']]],
]),
CURLOPT_WRITEFUNCTION => function ($ch, string $chunk): int {
// Forward only the text deltas to keep the browser payload tiny.
foreach (explode("\n", $chunk) as $line) {
if (!str_starts_with($line, 'data: ')) continue;
$event = json_decode(substr($line, 6), true);
if (($event['type'] ?? '') === 'content_block_delta') {
echo 'data: ' . wp_json_encode(['t' => $event['delta']['text']]) . "\n\n";
flush();
}
}
return strlen($chunk);
},
CURLOPT_TIMEOUT => 60,
]);
curl_exec($ch);
curl_close($ch);
echo "data: [DONE]\n\n";
flush();
exit;
}
On the JavaScript side an EventSource cannot POST, so use fetch with a ReadableStream reader and append t to the textarea as events arrive. If tokens still arrive in one lump, it is almost always a proxy buffering the response: the X-Accel-Buffering header fixes Nginx, and on Cloudflare you need to bypass the cache for that route.
Rule three: prompts are code
I keep prompts as versioned constants and store the version alongside each generated artefact (see _ai_summary_prompt_version above). When a prompt changes, a WP-CLI command regenerates only the rows produced by older versions. The practices behind that, including schema-constrained output and automated evals, are in Prompt Engineering for Developers.
<?php
final class LLM_Prompts {
public const SUMMARY_SYSTEM_V2 = <<<'TXT'
You summarise blog posts for a busy reader. Output exactly two sentences, under 45 words total,
in plain English, no marketing language, no first person. Do not mention that this is a summary.
TXT;
public const REWRITE_SYSTEM_V1 = <<<'TXT'
Rewrite the user's paragraph for clarity and rhythm. Keep the meaning, keep any technical terms,
keep roughly the same length. Return only the rewritten paragraph.
TXT;
}
What this looks like on a real client site
On a 1,400-post publisher site (custom theme, see why I avoid page builders), the initial backfill ran through Action Scheduler at 40 posts per minute, cost roughly $9 in tokens, and the editors now get AI summaries and an in-editor rewrite button without a single change to page load time. The monthly cap has tripped once, when a plugin update triggered save_post on every post; the hash check meant only the alert email fired and no tokens were spent.
If your site is headless, the same job architecture moves to the Next.js side; the ingestion patterns in my RAG pipeline guide apply directly. And if you want AI assistants to act on WordPress rather than WordPress calling AI, the MCP server tutorial is the other half of the picture.