All articlesAI

Adding AI to a WordPress plugin with the Claude API

May 21, 20266 min read

Adding an AI feature to a WordPress plugin sounds complex, but the integration surface is smaller than it looks. Claude's API is a standard HTTPS endpoint — any server that can make an outbound HTTP request can call it. WordPress, running PHP on a standard host, qualifies easily.

This post walks through three layers: the PHP side that calls Claude, a REST API endpoint to expose it, and a Gutenberg block that consumes it.

The PHP layer

WordPress ships with wp_remote_post(), a wrapper around the HTTP API that handles timeouts, SSL, and redirects correctly. Use it instead of raw curl or file_get_contents so your plugin respects the host's HTTP settings.

function my_plugin_call_claude( string $prompt ): string|WP_Error {
    $api_key = get_option( 'my_plugin_claude_api_key' );

    if ( empty( $api_key ) ) {
        return new WP_Error( 'no_api_key', 'Claude API key is not configured.' );
    }

    $response = wp_remote_post(
        'https://api.anthropic.com/v1/messages',
        [
            'timeout' => 30,
            'headers' => [
                'x-api-key'         => $api_key,
                'anthropic-version' => '2023-06-01',
                'content-type'      => 'application/json',
            ],
            'body' => wp_json_encode( [
                'model'      => 'claude-sonnet-4-6',
                'max_tokens' => 1024,
                'messages'   => [
                    [ 'role' => 'user', 'content' => $prompt ],
                ],
            ] ),
        ]
    );

    if ( is_wp_error( $response ) ) {
        return $response;
    }

    $status = wp_remote_retrieve_response_code( $response );
    if ( $status !== 200 ) {
        return new WP_Error( 'claude_error', 'Claude API returned status ' . $status );
    }

    $body = json_decode( wp_remote_retrieve_body( $response ), true );
    return $body['content'][0]['text'] ?? '';
}

Store the API key with get_option() / update_option() rather than hardcoding it. Add a settings page under Settings → Your Plugin using the Settings API, so the site owner can enter their own key.

Registering the REST endpoint

Expose the AI feature through a REST route so JavaScript on the front end or in the block editor can reach it:

add_action( 'rest_api_init', function () {
    register_rest_route( 'my-plugin/v1', '/generate', [
        'methods'             => 'POST',
        'callback'            => 'my_plugin_rest_generate',
        'permission_callback' => function () {
            return current_user_can( 'edit_posts' );
        },
        'args' => [
            'prompt' => [
                'required'          => true,
                'type'              => 'string',
                'sanitize_callback' => 'sanitize_textarea_field',
            ],
        ],
    ] );
} );

function my_plugin_rest_generate( WP_REST_Request $request ): WP_REST_Response|WP_Error {
    $prompt = $request->get_param( 'prompt' );
    $result = my_plugin_call_claude( $prompt );

    if ( is_wp_error( $result ) ) {
        return $result;
    }

    return new WP_REST_Response( [ 'text' => $result ], 200 );
}

The permission_callback is mandatory — omitting it leaves the endpoint open to unauthenticated requests. Checking current_user_can( 'edit_posts' ) ensures only logged-in editors and above can trigger Claude calls, which prevents abuse and unexpected API costs.

The Gutenberg block

With the REST endpoint in place, calling it from a block is straightforward. Use apiFetch — WordPress's wrapper around fetch that handles nonces and the REST base URL automatically.

// src/edit.tsx
import { useState } from "@wordpress/element";
import { Button, TextareaControl, Spinner } from "@wordpress/components";
import apiFetch from "@wordpress/api-fetch";
import { useBlockProps } from "@wordpress/block-editor";

export function Edit({ attributes, setAttributes }) {
  const [prompt, setPrompt] = useState("");
  const [loading, setLoading] = useState(false);
  const blockProps = useBlockProps();

  async function generate() {
    if (!prompt.trim()) return;
    setLoading(true);
    try {
      const res = await apiFetch<{ text: string }>({
        path: "/my-plugin/v1/generate",
        method: "POST",
        data: { prompt },
      });
      setAttributes({ content: res.text });
    } finally {
      setLoading(false);
    }
  }

  return (
    <div {...blockProps}>
      <TextareaControl
        label="Prompt"
        value={prompt}
        onChange={setPrompt}
        rows={3}
      />
      <Button variant="primary" onClick={generate} disabled={loading}>
        {loading ? <Spinner /> : "Generate with Claude"}
      </Button>
      {attributes.content && (
        <div className="generated-content">{attributes.content}</div>
      )}
    </div>
  );
}

apiFetch automatically attaches the X-WP-Nonce header using the nonce WordPress injects into the editor, so you do not need to handle authentication manually in the block.

Rate limiting and cost control

The REST endpoint above calls Claude on every request. In production, add three guards:

Transient caching for identical prompts. If the same prompt is likely to recur (e.g. generating meta descriptions for a post type), cache the result locally so the second call never hits the API:

$cache_key = 'claude_' . md5( $prompt );
$cached    = get_transient( $cache_key );

if ( $cached !== false ) {
    return $cached;
}

$result = my_plugin_call_claude( $prompt );
set_transient( $cache_key, $result, HOUR_IN_SECONDS );

Prompt caching on the API side. When you have a long, stable system prompt (a brand voice guide, a content style spec, a reference document) that you send with every variable user prompt, mark it with cache_control so Anthropic caches the prefix server-side. This cuts cost by up to 90% on cache hits and is significantly faster than re-processing the same prefix every call:

'system' => [
    [
        'type'          => 'text',
        'text'          => $brand_voice_guide,
        'cache_control' => [ 'type' => 'ephemeral' ],
    ],
],

The two caches solve different problems. Transient caching avoids the API call entirely for repeated prompts. Prompt caching makes every API call cheaper when prompts share a common prefix.

Usage cap. Track daily call counts with a transient and return an error once a threshold is hit. This prevents a runaway script or a rogue user from exhausting the API budget.

Keeping the API key secure

Never expose the key in JavaScript or in REST responses. The PHP layer is the only caller — the block sends a prompt string and receives generated text back, never touching the key. Store it with get_option() and strip it from any export or debug output.

If the plugin is distributed, document clearly that users need their own Anthropic account and API key. Each site owner bears the cost of their own usage, which is the correct model for a distributed plugin.


Farhan Shafi
Farhan Shafi
Full-Stack Developer