Skip to chapter content

Chapter 28 5 min read

Capstone 1: AI Alt-Text Generator

Prompt + system instruction + JSON schema + error handling + feature detection + REST endpoint — admin/Media Library feature

This chapter builds a real Media Library feature: a “Generate with AI” button next to any image’s Alternative Text field. Click it, and a real, accessible description gets written and saved, no new concepts, just five things from earlier chapters working together.

Writing the alt text

function ai_course_ch28_generate_alt_text( WP_REST_Request $request ) {
    $attachment_id = $request->get_param( 'attachment_id' );

    // Confirms this is a real attachment before doing anything else with it.
    if ( 'attachment' !== get_post_type( $attachment_id ) ) {
        return new WP_Error( 'ai_course_invalid_attachment', 'That attachment could not be found.' );
    }

    $file_path = get_attached_file( $attachment_id );
    $mime_type = get_post_mime_type( $attachment_id );

    // Only an actual image can be described this way.
    if ( ! $file_path || 0 !== strpos( (string) $mime_type, 'image/' ) ) {
        return new WP_Error( 'ai_course_not_an_image', 'AI alt text only works on image attachments.' );
    }

    // Forces the reply down to one clean field, nothing else to parse
    // out of a longer answer.
    $schema = array(
        'type'                 => 'object',
        'properties'           => array(
            'alt_text' => array(
                'type'        => 'string',
                'description' => 'Concise, descriptive alt text for the image, no more than 125 characters.',
            ),
        ),
        'required'             => array( 'alt_text' ),
        'additionalProperties' => false,
    );

    // Three earlier chapters in one prompt: a role (Ch. 5), the actual
    // image attached (Ch. 15), and a forced JSON shape (Ch. 8).
    $builder = wp_ai_client_prompt( 'Write accessible alt text for this image.' )
        ->using_system_instruction( 'You are an accessibility expert writing alt text for images on a WordPress website. Alt text should be concise, descriptive, and useful to someone using a screen reader. Never start with phrases like "image of" or "picture of".' )
        ->with_file( $file_path, $mime_type )
        ->as_json_response( $schema );

    // Checks this exact configuration, not a generic version of it.
    if ( ! $builder->is_supported_for_text_generation() ) {
        return new WP_Error( 'ai_course_not_supported', 'The configured AI provider does not support this right now.' );
    }

    $result = $builder->generate_text();

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

    $data = json_decode( $result, true );

    if ( ! isset( $data['alt_text'] ) ) {
        return new WP_Error( 'ai_course_bad_response', 'The AI response was not in the expected shape.' );
    }

    // WordPress's own alt-text field, not a separate custom one, this
    // is the same meta key the Media Library itself reads and writes.
    update_post_meta( $attachment_id, '_wp_attachment_image_alt', sanitize_text_field( $data['alt_text'] ) );

    return rest_ensure_response( array( 'alt_text' => $data['alt_text'] ) );
}

Five things from earlier chapters, in the order they actually run.

using_system_instruction(), from Chapter 5, gives the model a job before it sees anything else: writing accessible alt text, not just describing a picture generically.

with_file(), from Chapter 15, is what actually lets the model see the image. get_attached_file() and get_post_mime_type() are the same plain WordPress functions that chapter used to find the file and its type.

as_json_response(), from Chapter 8, is what makes the reply predictable. Without a schema, a model asked to “write alt text” might reply with a sentence, a bulleted list of options, or a paragraph explaining its choice. The schema forces exactly one field back, alt_text, nothing else to parse out of a longer reply.

is_supported_for_text_generation(), from Chapter 17, runs on the fully built $builder. The system instruction, file, and schema are all already attached by then, right before generating. That’s deliberate, it checks the exact configuration about to be used, not some more generic, less accurate version of it.

is_wp_error( $result ), the habit from Chapter 3, catches whatever actually went wrong, an unsupported provider, a request that failed, a reply that didn’t match the schema, and returns it directly rather than continuing with bad data.

Where the result actually goes

_wp_attachment_image_alt is WordPress’s own post meta key for an attachment’s alt text, the exact field the Media Library reads from and writes to. Saving to it here means the generated text isn’t just returned to the browser, it becomes the image’s real alt text immediately, the same as if you’d typed it in yourself.

Adding the button to the Media Library

function ai_course_ch28_add_button( $form_fields, $post ) {
    if ( ! current_user_can( 'edit_post', $post->ID ) ) {
        return $form_fields;
    }

    $button_field = array(
        'ai_course_ch28_generate' => array(
            'label' => 'AI Alt Text',
            'input' => 'html',
            'html'  => '<button type="button" class="button ai-course-ch28-button" data-attachment-id="' . esc_attr( $post->ID ) . '">Generate with AI</button>',
        ),
    );

    // Inserts right after WordPress's own Alternative Text field
    // ('image_alt'), instead of at the end of the whole form.
    $position = array_search( 'image_alt', array_keys( $form_fields ), true );

    if ( false === $position ) {
        return $form_fields + $button_field;
    }

    return array_slice( $form_fields, 0, $position + 1, true )
        + $button_field
        + array_slice( $form_fields, $position + 1, null, true );
}
add_filter( 'attachment_fields_to_edit', 'ai_course_ch28_add_button', 10, 2 );

attachment_fields_to_edit is the filter WordPress itself uses to build the fields you see when editing an image, Alternative Text and Caption among them. Simply adding a new key to $form_fields appends it to the end of the whole form, after File URL and everything else, not next to the field it’s actually related to.

array_slice() splits the existing fields into everything up to and including image_alt, and everything after it, then rebuilds the array with the new button field inserted right in the middle. The button ends up directly under Alternative Text, where it visually belongs.

This filter runs both in the Media Library’s edit screen and in the media modal you get when adding an image to a post. The button shows up in both places without any extra code.

A different admin hook

function ai_course_ch28_enqueue_assets( $hook ) {
    // An admin-only feature, so only load on the screens where the
    // button can actually appear, not every admin page.
    if ( ! in_array( $hook, array( 'post.php', 'post-new.php', 'upload.php' ), true ) ) {
        return;
    }

    wp_enqueue_script(
        'ai-course-ch28-alt-text',
        plugins_url( 'js/chapter-28-alt-text.js', __FILE__ ),
        array( 'wp-api-fetch' ),
        '1.0',
        true
    );

    wp_add_inline_script(
        'ai-course-ch28-alt-text',
        sprintf(
            'wp.apiFetch.use( wp.apiFetch.createNonceMiddleware( %s ) );',
            wp_json_encode( wp_create_nonce( 'wp_rest' ) )
        ),
        'before'
    );
}
// Admin-only feature, so this hooks the admin equivalent of
// wp_enqueue_scripts, used everywhere else in this book.
add_action( 'admin_enqueue_scripts', 'ai_course_ch28_enqueue_assets' );

Every earlier chapter that loaded a script used wp_enqueue_scripts, the frontend hook. This is an admin-only feature, so it uses admin_enqueue_scripts instead, WordPress’s equivalent for admin screens.

The $hook check limits loading to three screens: editing a post, creating one, and the Media Library itself. Those are the only places the button can actually appear, so nothing loads anywhere else.

The button doesn’t exist yet when the page loads

function ai_course_ch28_init() {
    // The button lives inside the media modal, which doesn't exist in
    // the page until it's actually opened, so this listens on the
    // whole document instead of the button itself.
    document.addEventListener( 'click', ( event ) => {
        if ( ! event.target.classList.contains( 'ai-course-ch28-button' ) ) {
            return;
        }

        const button = event.target;
        const attachmentId = button.getAttribute( 'data-attachment-id' );

        // WordPress renders the Alternative Text field with a different
        // ID depending on which screen you're on: the classic media
        // modal namespaces it by attachment ID, the newer "two column"
        // Attachment Details screen uses one fixed ID since only one
        // attachment is ever shown on it at a time. Try each in turn.
        const altField =
            document.getElementById( 'attachment-details-two-column-alt-text' ) ||
            document.getElementById( 'attachments-' + attachmentId + '-image_alt' ) ||
            document.getElementById( 'attachments-' + attachmentId + '-alt' );

        if ( ! altField ) {
            return;
        }

        button.disabled = true;
        button.textContent = 'Generating...';

        wp.apiFetch( {
            path: '/ai-course/v1/alt-text',
            method: 'POST',
            data: { attachment_id: attachmentId }
        } )
            .then( ( data ) => {
                altField.value = data.alt_text;
                button.disabled = false;
                button.textContent = 'Generate with AI';
            } )
            .catch( ( error ) => {
                alert( error.message || 'Something went wrong, please try again.' );
                button.disabled = false;
                button.textContent = 'Generate with AI';
            } );
    } );
}

// If scripts load after the page has already finished loading (common
// with some caching/optimization plugins), DOMContentLoaded has already
// fired and never will again, so check readyState first.
if ( 'loading' === document.readyState ) {
    document.addEventListener( 'DOMContentLoaded', ai_course_ch28_init );
} else {
    ai_course_ch28_init();
}

The media modal opens and closes on demand. Its fields, this button included, don’t exist in the page until you actually open it. A listener attached directly to the button at page load would find nothing there yet.

The click listener is attached to document instead, and checks whether whatever was clicked has the button’s class. Opened once or opened five times, it doesn’t matter, the listener was never waiting on the button existing in the first place.

A real reader hit a real bug here, and it took two attempts to actually fix. The first guess was that the field’s ID followed the classic attachments-{id}-alt pattern from the media modal. It didn’t, WordPress builds each field’s ID from its array key, and the real key is image_alt, not alt.

The second attempt fixed that, but the reader was actually on a different screen entirely, the newer “two column” Attachment Details layout, which doesn’t use that pattern at all. It renders Alternative Text with one fixed ID, attachment-details-two-column-alt-text, no attachment ID involved, since only one attachment is ever shown on that page.

The fix that actually works checks all three known patterns in order, so the button works whichever screen you’re actually on, rather than betting on just one.

Putting it together

Create chapter-28-alt-text.php inside includes:

<?php
/**
* Chapter 28: Capstone 1, AI Alt-Text Generator
* Usage: open any image in the Media Library or the media modal, click
* "Generate with AI" next to Alternative Text.
*/
if ( ! defined( 'ABSPATH' ) ) {
exit; // No direct access.
}
function ai_course_ch28_register_rest_route() {
register_rest_route(
'ai-course/v1',
'/alt-text',
array(
'methods' => 'POST',
'callback' => 'ai_course_ch28_generate_alt_text',
'permission_callback' => function () {
return current_user_can( 'upload_files' );
},
'args' => array(
'attachment_id' => array(
'required' => true,
'type' => 'integer',
'sanitize_callback' => 'absint',
),
),
)
);
}
add_action( 'rest_api_init', 'ai_course_ch28_register_rest_route' );
function ai_course_ch28_generate_alt_text( WP_REST_Request $request ) {
$attachment_id = $request->get_param( 'attachment_id' );
// Confirms this is a real attachment before doing anything else with it.
if ( 'attachment' !== get_post_type( $attachment_id ) ) {
return new WP_Error( 'ai_course_invalid_attachment', 'That attachment could not be found.' );
}
$file_path = get_attached_file( $attachment_id );
$mime_type = get_post_mime_type( $attachment_id );
// Only an actual image can be described this way.
if ( ! $file_path || 0 !== strpos( (string) $mime_type, 'image/' ) ) {
return new WP_Error( 'ai_course_not_an_image', 'AI alt text only works on image attachments.' );
}
// Forces the reply down to one clean field, nothing else to parse
// out of a longer answer.
$schema = array(
'type' => 'object',
'properties' => array(
'alt_text' => array(
'type' => 'string',
'description' => 'Concise, descriptive alt text for the image, no more than 125 characters.',
),
),
'required' => array( 'alt_text' ),
'additionalProperties' => false,
);
// Three earlier chapters in one prompt: a role (Ch. 5), the actual
// image attached (Ch. 15), and a forced JSON shape (Ch. 8).
$builder = wp_ai_client_prompt( 'Write accessible alt text for this image.' )
->using_system_instruction( 'You are an accessibility expert writing alt text for images on a WordPress website. Alt text should be concise, descriptive, and useful to someone using a screen reader. Never start with phrases like "image of" or "picture of".' )
->with_file( $file_path, $mime_type )
->as_json_response( $schema );
// Checks this exact configuration, not a generic version of it.
if ( ! $builder->is_supported_for_text_generation() ) {
return new WP_Error( 'ai_course_not_supported', 'The configured AI provider does not support this right now.' );
}
$result = $builder->generate_text();
if ( is_wp_error( $result ) ) {
return $result;
}
$data = json_decode( $result, true );
if ( ! isset( $data['alt_text'] ) ) {
return new WP_Error( 'ai_course_bad_response', 'The AI response was not in the expected shape.' );
}
// WordPress's own alt-text field, not a separate custom one, this
// is the same meta key the Media Library itself reads and writes.
update_post_meta( $attachment_id, '_wp_attachment_image_alt', sanitize_text_field( $data['alt_text'] ) );
return rest_ensure_response( array( 'alt_text' => $data['alt_text'] ) );
}
function ai_course_ch28_add_button( $form_fields, $post ) {
if ( ! current_user_can( 'edit_post', $post->ID ) ) {
return $form_fields;
}
$button_field = array(
'ai_course_ch28_generate' => array(
'label' => 'AI Alt Text',
'input' => 'html',
'html' => '<button type="button" class="button ai-course-ch28-button" data-attachment-id="' . esc_attr( $post->ID ) . '">Generate with AI</button>',
),
);
// Inserts right after WordPress's own Alternative Text field
// ('image_alt'), instead of at the end of the whole form.
$position = array_search( 'image_alt', array_keys( $form_fields ), true );
if ( false === $position ) {
return $form_fields + $button_field;
}
return array_slice( $form_fields, 0, $position + 1, true )
+ $button_field
+ array_slice( $form_fields, $position + 1, null, true );
}
add_filter( 'attachment_fields_to_edit', 'ai_course_ch28_add_button', 10, 2 );
function ai_course_ch28_enqueue_assets( $hook ) {
// An admin-only feature, so only load on the screens where the
// button can actually appear, not every admin page.
if ( ! in_array( $hook, array( 'post.php', 'post-new.php', 'upload.php' ), true ) ) {
return;
}
wp_enqueue_script(
'ai-course-ch28-alt-text',
plugins_url( 'js/chapter-28-alt-text.js', __FILE__ ),
array( 'wp-api-fetch' ),
'1.0',
true
);
wp_add_inline_script(
'ai-course-ch28-alt-text',
sprintf(
'wp.apiFetch.use( wp.apiFetch.createNonceMiddleware( %s ) );',
wp_json_encode( wp_create_nonce( 'wp_rest' ) )
),
'before'
);
}
// Admin-only feature, so this hooks the admin equivalent of
// wp_enqueue_scripts, used everywhere else in this book.
add_action( 'admin_enqueue_scripts', 'ai_course_ch28_enqueue_assets' );

Then create chapter-28-alt-text.js inside includes/js:

function ai_course_ch28_init() {
// The button lives inside the media modal, which doesn't exist in
// the page until it's actually opened, so this listens on the
// whole document instead of the button itself.
document.addEventListener( 'click', ( event ) => {
if ( ! event.target.classList.contains( 'ai-course-ch28-button' ) ) {
return;
}
const button = event.target;
const attachmentId = button.getAttribute( 'data-attachment-id' );
// WordPress renders the Alternative Text field with a different
// ID depending on which screen you're on: the classic media
// modal namespaces it by attachment ID, the newer "two column"
// Attachment Details screen uses one fixed ID since only one
// attachment is ever shown on it at a time. Try each in turn.
const altField =
document.getElementById( 'attachment-details-two-column-alt-text' ) ||
document.getElementById( 'attachments-' + attachmentId + '-image_alt' ) ||
document.getElementById( 'attachments-' + attachmentId + '-alt' );
if ( ! altField ) {
return;
}
button.disabled = true;
button.textContent = 'Generating...';
wp.apiFetch( {
path: '/ai-course/v1/alt-text',
method: 'POST',
data: { attachment_id: attachmentId }
} )
.then( ( data ) => {
altField.value = data.alt_text;
button.disabled = false;
button.textContent = 'Generate with AI';
} )
.catch( ( error ) => {
alert( error.message || 'Something went wrong, please try again.' );
button.disabled = false;
button.textContent = 'Generate with AI';
} );
} );
}
// If scripts load after the page has already finished loading (common
// with some caching/optimization plugins), DOMContentLoaded has already
// fired and never will again, so check readyState first.
if ( 'loading' === document.readyState ) {
document.addEventListener( 'DOMContentLoaded', ai_course_ch28_init );
} else {
ai_course_ch28_init();
}

Open any image in your Media Library, or add one to a post and open its details. A “Generate with AI” button appears next to Alternative Text. Click it, and a few seconds later, real alt text appears in that field, already saved.

Worth knowing

The button appears on every image, including ones that already have alt text. Clicking it overwrites whatever’s there, there’s no confirmation step. That’s a reasonable default for a book example, but a real plugin would probably warn before replacing existing text.

Try it yourself

Add a second button, or a checkbox, that also generates a longer image description for the longdesc use case, a couple of sentences instead of one short phrase, using a second field in the same JSON schema rather than a second request.

This book is created with Chapterwright