Skip to chapter content

Chapter 20 5 min read

Exposing AI to the Frontend, Safely

Why there's no direct client-side call; building your own scoped REST endpoint

Every shortcode in this book runs the moment a page loads. That’s fine for a demo, useless for a real feature, a button someone clicks, a form someone submits, without reloading the page. That needs JavaScript talking to the server. But wp_ai_client_prompt() is PHP, it doesn’t exist in the browser. Something has to bridge the two.

Why not just let JavaScript call the AI directly

The tempting shortcut is exposing something in JavaScript that accepts any prompt and forwards it straight to whatever’s configured. Don’t. That means any visitor who can reach that endpoint can send arbitrary prompts through your site’s connected AI account, at your cost, for whatever they want, not just the feature you built.

The safer shape is your own REST endpoint, scoped to exactly one thing. The browser sends data, a business description, a post ID, whatever the feature actually needs. Your PHP code decides what prompt that data gets wrapped into. The browser never gets to choose the prompt itself, only the input to it.

Building the endpoint

function ai_course_ch20_register_rest_route() {
    register_rest_route(
        'ai-course/v1',
        '/tagline',
        array(
            'methods'             => 'POST',
            'callback'            => 'ai_course_ch20_generate_tagline',
            'permission_callback' => function () {
                return current_user_can( 'edit_posts' );
            },
            'args'                => array(
                'business' => array(
                    'required'          => true,
                    'type'              => 'string',
                    'sanitize_callback' => 'sanitize_text_field',
                ),
            ),
        )
    );
}
add_action( 'rest_api_init', 'ai_course_ch20_register_rest_route' );

function ai_course_ch20_generate_tagline( WP_REST_Request $request ) {
    $business = $request->get_param( 'business' );

    $result = wp_ai_client_prompt( "Write a short, punchy tagline for a business described as: {$business}" )
        ->using_temperature( 0.9 )
        ->generate_text_result();

    return rest_ensure_response( $result );
}

permission_callback is what actually enforces who’s allowed to hit this endpoint, current_user_can( 'edit_posts' ) here, meaning only logged-in users who can already edit content can trigger it. The prompt itself is a fixed template, "Write a short, punchy tagline for a business described as: {$business}", the visitor only ever supplies the {$business} part.

Notice business gets sanitized through sanitize_callback in the args definition, not with a manual sanitize_text_field() call inside the callback. That’s the pattern WordPress’s own REST API documentation recommends: sanitize and validate at the argument level, so the callback itself can just assume the data it receives is already safe to use, rather than every callback re-implementing its own cleanup.

The last line is doing more than it looks like. rest_ensure_response() accepts a GenerativeAiResult directly and turns it into a proper JSON response. Hand it a WP_Error instead, from a failed call, and it automatically becomes a response with the right HTTP error status attached, no manual translation between “AI Client failure” and “HTTP response” required on your part.

Calling it from the browser

function ai_course_ch20_tagline_form() {
    if ( ! current_user_can( 'edit_posts' ) ) {
        return '<p><em>Log in with an account that can edit posts to try this.</em></p>';
    }

    wp_enqueue_script( 'wp-api-fetch' );

    wp_add_inline_script(
        'wp-api-fetch',
        sprintf(
            'wp.apiFetch.use( wp.apiFetch.createNonceMiddleware( %s ) );',
            wp_json_encode( wp_create_nonce( 'wp_rest' ) )
        ),
        'after'
    );

    wp_add_inline_script(
        'wp-api-fetch',
        "
        document.addEventListener( 'DOMContentLoaded', function () {
            var button = document.getElementById( 'ai-course-ch20-button' );

            if ( ! button ) {
                return;
            }

            button.addEventListener( 'click', function () {
                var business   = document.getElementById( 'ai-course-ch20-business' ).value;
                var taglineBox = document.getElementById( 'ai-course-ch20-tagline' );
                var resultBox  = document.getElementById( 'ai-course-ch20-result' );

                taglineBox.textContent = 'Generating...';
                resultBox.textContent  = '';

                wp.apiFetch( {
                    path: '/ai-course/v1/tagline',
                    method: 'POST',
                    data: { business: business }
                } )
                    .then( function ( data ) {
                        var tagline = '';

                        if ( data.candidates && data.candidates[ 0 ] && data.candidates[ 0 ].message ) {
                            data.candidates[ 0 ].message.parts.forEach( function ( part ) {
                                if ( part.text ) {
                                    tagline += part.text;
                                }
                            } );
                        }

                        taglineBox.textContent = tagline || '(No text found in the response.)';
                        resultBox.textContent  = JSON.stringify( data, null, 2 );
                    } )
                    .catch( function ( error ) {
                        taglineBox.textContent = 'Request failed.';
                        resultBox.textContent  = JSON.stringify( error, null, 2 );
                    } );
            } );
        } );
        ",
        'after'
    );

    return '
        <div id="ai-course-ch20">
            <input type="text" id="ai-course-ch20-business" placeholder="Describe your business..." style="width:100%; max-width:400px;">
            <button id="ai-course-ch20-button">Generate Tagline</button>
            <p id="ai-course-ch20-tagline" style="font-size:1.2em; font-weight:bold; margin-top:12px;"></p>
            <details style="margin-top:10px;">
                <summary>Full response</summary>
                <pre id="ai-course-ch20-result" style="white-space:pre-wrap; background:#f0f0f0; padding:12px;"></pre>
            </details>
        </div>
    ';
}
add_shortcode( 'ai_course_ch20', 'ai_course_ch20_tagline_form' );

This uses wp.apiFetch() rather than a plain fetch() call. It does three things a raw fetch() would leave you to handle by hand:

  • It resolves the path against your site’s actual REST URL automatically.
  • It parses the JSON response for you.
  • It rejects the promise outright on an error response, instead of quietly resolving with an error body you’d have to check for yourself.

The nonce isn’t hardcoded into the request this time either. wp.apiFetch.createNonceMiddleware() registers it once, and every apiFetch() call after that automatically includes it, the same mechanism WordPress’s own block editor uses for every REST request it makes.

Notice both scripts are added with wp_add_inline_script( 'wp-api-fetch', ..., 'after' ) rather than printed as a raw <script> tag in the HTML. That’s not just tidiness.

A script tag sitting directly in a shortcode’s output has no guaranteed relationship to when wp-api-fetch itself finishes loading. If that ever happened out of order, wp.apiFetch wouldn’t exist yet and the button would silently do nothing. Attaching both pieces to the wp-api-fetch handle guarantees they run only after it’s actually available.

Seeing it all together

Create chapter-20-rest-endpoint.php inside includes, combining everything from both sections above into one file:

<?php
/**
* Chapter 20: Exposing AI to the Frontend, Safely
* Usage: add [ai_course_ch20] to any page or post. Requires being logged
* in with the edit_posts capability to actually use the form.
*/
if ( ! defined( 'ABSPATH' ) ) {
exit; // No direct access.
}
function ai_course_ch20_register_rest_route() {
register_rest_route(
'ai-course/v1',
'/tagline',
array(
'methods' => 'POST',
'callback' => 'ai_course_ch20_generate_tagline',
'permission_callback' => function () {
return current_user_can( 'edit_posts' );
},
'args' => array(
'business' => array(
'required' => true,
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
),
),
)
);
}
add_action( 'rest_api_init', 'ai_course_ch20_register_rest_route' );
function ai_course_ch20_generate_tagline( WP_REST_Request $request ) {
$business = $request->get_param( 'business' );
$result = wp_ai_client_prompt( "Write a short, punchy tagline for a business described as: {$business}" )
->using_temperature( 0.9 )
->generate_text_result();
return rest_ensure_response( $result );
}
function ai_course_ch20_tagline_form() {
if ( ! current_user_can( 'edit_posts' ) ) {
return '<p><em>Log in with an account that can edit posts to try this.</em></p>';
}
wp_enqueue_script( 'wp-api-fetch' );
wp_add_inline_script(
'wp-api-fetch',
sprintf(
'wp.apiFetch.use( wp.apiFetch.createNonceMiddleware( %s ) );',
wp_json_encode( wp_create_nonce( 'wp_rest' ) )
),
'after'
);
wp_add_inline_script(
'wp-api-fetch',
"
document.addEventListener( 'DOMContentLoaded', function () {
var button = document.getElementById( 'ai-course-ch20-button' );
if ( ! button ) {
return;
}
button.addEventListener( 'click', function () {
var business = document.getElementById( 'ai-course-ch20-business' ).value;
var taglineBox = document.getElementById( 'ai-course-ch20-tagline' );
var resultBox = document.getElementById( 'ai-course-ch20-result' );
taglineBox.textContent = 'Generating...';
resultBox.textContent = '';
wp.apiFetch( {
path: '/ai-course/v1/tagline',
method: 'POST',
data: { business: business }
} )
.then( function ( data ) {
var tagline = '';
if ( data.candidates && data.candidates[ 0 ] && data.candidates[ 0 ].message ) {
data.candidates[ 0 ].message.parts.forEach( function ( part ) {
if ( part.text ) {
tagline += part.text;
}
} );
}
taglineBox.textContent = tagline || '(No text found in the response.)';
resultBox.textContent = JSON.stringify( data, null, 2 );
} )
.catch( function ( error ) {
taglineBox.textContent = 'Request failed.';
resultBox.textContent = JSON.stringify( error, null, 2 );
} );
} );
} );
",
'after'
);
return '
<div id="ai-course-ch20">
<input type="text" id="ai-course-ch20-business" placeholder="Describe your business..." style="width:100%; max-width:400px;">
<button id="ai-course-ch20-button">Generate Tagline</button>
<p id="ai-course-ch20-tagline" style="font-size:1.2em; font-weight:bold; margin-top:12px;"></p>
<details style="margin-top:10px;">
<summary>Full response</summary>
<pre id="ai-course-ch20-result" style="white-space:pre-wrap; background:#f0f0f0; padding:12px;"></pre>
</details>
</div>
';
}
add_shortcode( 'ai_course_ch20', 'ai_course_ch20_tagline_form' );

Add [ai_course_ch20] to a page and load it. Type a business description, click the button, and the tagline itself appears right there, no page reload, a real interactive feature instead of a shortcode that only runs once on page load.

Reading the response

The click handler pulls the actual tagline out of data.candidates[0].message.parts, the same message-and-parts shape you’ve already read apart in earlier chapters, and shows it as the main result.

The full JSON response still gets dumped too, just tucked into a collapsed “Full response” details block below it instead of being the only thing on screen. The tagline is what makes this feel like an actual feature. The raw dump underneath is what taught you the shape in the first place, and it’s there for whenever something looks wrong and you need to see exactly what came back.

Here’s what’s actually inside that dump, confirmed from a real response:

{
    "candidates": [
        {
            "message": {
                "role": "model",
                "parts": [
                    { "channel": "content", "type": "text", "text": "Hand-poured glow, crafted to inspire." }
                ]
            },
            "finishReason": "stop"
        }
    ],
    "tokenUsage": {
        "promptTokens": 21,
        "completionTokens": 13,
        "totalTokens": 34
    },
    "providerMetadata": { "id": "openai", "name": "OpenAI", "...": "..." },
    "modelMetadata": { "id": "gpt-5.4", "name": "gpt-5.4", "...": "..." },
    "additionalData": { "...": "raw, provider-specific response data" }
}

tokenUsage, providerMetadata, and modelMetadata match what Chapter 18 covers. additionalData is different: a raw passthrough of whatever the specific provider’s own API returned underneath, treat it as debug information only, never something your code depends on.

Everything else is the AI Client’s own normalized shape, meant to look the same no matter which provider actually answered. Turning each provider’s different native format into one consistent structure is explicitly what that normalization layer is for.

That’s also why the click handler checks if ( data.candidates && data.candidates[ 0 ] && data.candidates[ 0 ].message ) before touching anything inside it. If a response ever doesn’t match the expected shape, for any reason, the code falls through to “(No text found in the response.)” instead of throwing a JavaScript error and breaking the page.

Confirmed across providers, with one real surprise

The same shortcode was tested against Gemini too, not just OpenAI. The core structure matched exactly, same field names, just different provider and model values. additionalData looked completely different between the two, confirming it really is provider-specific and not something to build on.

One surprise turned up along the way: on the Gemini response, promptTokens plus completionTokens didn’t add up to totalTokens, the total was significantly higher than expected. That’s likely the same invisible reasoning-token spending Chapter 7 covered. If you’re tracking cost, trust totalTokens, not the sum of the other two.

Try it yourself

Change the fixed prompt template to a different feature, an excerpt suggester, a meta description generator, anything that takes one piece of input and returns generated text. Keep the same shape: a scoped endpoint, a permission check, a fixed prompt template, user input only filling in the blank.

This book is created with Chapterwright