All articles
AI

Adding AI to a WordPress plugin with the Claude API

May 21, 2026Updated August 18, 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-5',
                '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'] ?? '';
}

Never hardcode the API key. A distributed plugin can store a site owner's key through a capability-protected Settings API screen; mark the option as non-autoloaded and redact it from exports and logs. For centrally managed sites, an environment variable or a constant defined outside version control is preferable because the secret does not enter the WordPress database.

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',
                'validate_callback' => function ( $value ) {
                    return is_string( $value ) && mb_strlen( $value ) <= 4000;
                },
            ],
        ],
    ] );
} );

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. Checking current_user_can( 'edit_posts' ) limits this example to users who can edit posts, while validation puts a hard ceiling on request size. In a real plugin, choose the narrowest capability that matches the feature and enforce per-user or per-site quotas as well.

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_input = wp_json_encode( [
    'model'   => 'claude-sonnet-5',
    'version' => 1,
    'prompt'  => $prompt,
] );
$cache_key = 'claude_' . md5( $cache_input );
$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.

Only cache responses that are genuinely independent of the current user and private site context. Include the model, system-prompt version, locale, and any other output-changing input in the key.

Usage cap. Track daily calls per user or site and return an error once a threshold is hit. A transient is adequate for a low-traffic plugin; higher-concurrency installations need an atomic counter or external rate limiter so simultaneous requests cannot bypass the cap.

Keeping the API key secure

Never expose the key in JavaScript or REST responses. The PHP layer is the only caller — the block sends a prompt string and receives generated text back, never touching the key. Redact the key and model responses from production logs unless you have an explicit, privacy-aware retention policy.

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.

Official references

Continue exploring

For model features beyond the basic request, read the practical Claude API guide. The AI content workflow guide covers validation and human review, while production Gutenberg architecture covers the block layer. See Reppod for a shipped AI workflow or discuss an AI integration.


Farhan Shafi
Farhan Shafi
WordPress Product Engineer · Full-Stack Developer

Related articles