{"id":6323,"date":"2026-07-30T17:39:44","date_gmt":"2026-07-30T17:39:44","guid":{"rendered":"https:\/\/developer.wordpress.org\/news\/?p=6323"},"modified":"2026-07-30T17:39:44","modified_gmt":"2026-07-30T17:39:44","slug":"build-your-first-ai-powered-wordpress-plugin","status":"publish","type":"post","link":"https:\/\/developer.wordpress.org\/news\/2026\/07\/build-your-first-ai-powered-wordpress-plugin\/","title":{"rendered":"Build your first AI-Powered WordPress plugin"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">The last two major WordPress releases have included important new features for developers wanting to explore AI in the context of WordPress:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>The <strong><a href=\"https:\/\/make.wordpress.org\/core\/2025\/11\/10\/abilities-api-in-wordpress-6-9\/\">Abilities API<\/a><\/strong> \u2014 a standard way to register a unit of functionality so that <em>anything<\/em> (REST, the block editor, another developer, an AI agent) can discover and run it.<\/li>\n\n\n\n<li>The <strong><a href=\"https:\/\/github.com\/WordPress\/php-ai-client\">WordPress AI Client<\/a><\/strong> \u2014 a provider-agnostic PHP library for interacting with any AI model, so your code doesn&#8217;t care which AI provider is available.<\/li>\n\n\n\n<li>The <strong><a href=\"https:\/\/github.com\/WordPress\/mcp-adapter\">MCP Adapter<\/a><\/strong> \u2014 which exposes Abilities as MCP tools, so an AI agent (desktop or cloud-based) can use them.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">So far, you\u2019ve probably read multiple tutorials on how to use each of these separately. We\u2019ve even covered each of them on the WordPress developer blog:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><a href=\"https:\/\/developer.wordpress.org\/news\/2025\/11\/introducing-the-wordpress-abilities-api\/\">Introducing the WordPress Abilities API<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/developer.wordpress.org\/news\/2026\/02\/from-abilities-to-ai-agents-introducing-the-wordpress-mcp-adapter\/\">From Abilities to AI Agents: Introducing the WordPress MCP Adapter<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/developer.wordpress.org\/news\/2026\/05\/how-to-build-an-image-generation-plugin-with-the-wordpress-ai-client\/\">How to build an image generation plugin with the WordPress AI Client<\/a><\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">The reality is that while each of these is fun to explore, it&#8217;s when you stitch them together that the magic really happens.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">If you&#8217;ve been watching the Core AI projects land and wondering how the pieces fit together in a real plugin, then this post is for you. We&#8217;re going to build one together, step by step, focusing on how to implement each of the three AI building blocks.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">If this is your first time learning about any of these concepts, we recommend going back and working through the three posts linked above, and then coming back here.<\/p>\n\n\n\n<div class=\"wp-block-group has-light-grey-2-background-color has-background is-layout-flow wp-block-group-is-layout-flow\" style=\"border-radius:2px;margin-top:var(--wp--preset--spacing--30);margin-bottom:var(--wp--preset--spacing--30);padding-top:var(--wp--preset--spacing--30);padding-right:var(--wp--preset--spacing--30);padding-bottom:var(--wp--preset--spacing--30);padding-left:var(--wp--preset--spacing--30)\">\n<p class=\"has-large-font-size wp-block-paragraph\" style=\"font-style:normal;font-weight:600;line-height:1\">Table of Contents<\/p>\n\n\n<nav aria-label=\"Table of Contents\" class=\"wp-block-table-of-contents\"><ol><li><a class=\"wp-block-table-of-contents__entry\" href=\"#what-we-re-building\">What we&#039;re building<\/a><\/li><li><a class=\"wp-block-table-of-contents__entry\" href=\"#before-you-start\">Before you start<\/a><\/li><li><a class=\"wp-block-table-of-contents__entry\" href=\"#connecting-your-site-to-your-ai-provider\">Connecting your site to your AI provider<\/a><\/li><li><a class=\"wp-block-table-of-contents__entry\" href=\"#building-the-plugin-functionality\">Building the plugin functionality<\/a><\/li><li><a class=\"wp-block-table-of-contents__entry\" href=\"#step-1-register-the-abilities\">Step 1: Register the abilities<\/a><\/li><li><a class=\"wp-block-table-of-contents__entry\" href=\"#step-2-hook-the-abilities-in\">Step 2: Hook the abilities in<\/a><\/li><li><a class=\"wp-block-table-of-contents__entry\" href=\"#step-3-check-your-work-over-rest\">Step 3: Check your work over REST<\/a><\/li><li><a class=\"wp-block-table-of-contents__entry\" href=\"#step-4-describe-the-image-your-first-ai-call\">Step 4: Describe the image (your first AI call)<\/a><\/li><li><a class=\"wp-block-table-of-contents__entry\" href=\"#step-5-generate-the-post-copy\">Step 5: Generate the post copy<\/a><\/li><li><a class=\"wp-block-table-of-contents__entry\" href=\"#step-6-compose-the-abilities-the-good-bit\">Step 6: Compose the abilities (the good bit)<\/a><\/li><li><a class=\"wp-block-table-of-contents__entry\" href=\"#step-7-update-the-admin-interface\">Step 7: Update the Admin Interface<\/a><ol><li><a class=\"wp-block-table-of-contents__entry\" href=\"#one-practical-tweak-the-timeout\">One practical tweak: the timeout<\/a><\/li><\/ol><\/li><li><a class=\"wp-block-table-of-contents__entry\" href=\"#step-8-hand-it-to-an-ai-agent-mcp\">Step 8: Hand it to an AI agent (MCP)<\/a><\/li><li><a class=\"wp-block-table-of-contents__entry\" href=\"#wrap-up\">Wrap up<\/a><\/li><\/ol><\/nav><\/div>\n\n\n\n<p class=\"wp-block-paragraph\"><em>The contents of this tutorial were also presented at a workshop at WordCamp Europe 2026. If you prefer to learn through video, you can <a href=\"https:\/\/wordpress.tv\/2026\/06\/08\/build-your-first-ai-powered-wordpress-plugin\/\">watch the recording on WordPress.tv<\/a>.<\/em><\/p>\n\n\n\n<h2 id=\"what-we-re-building\" class=\"wp-block-heading\">What we&#8217;re building<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The plugin is called <strong>Photo to Post<\/strong>, and the idea is straightforward. You give it the URL of an image, and the plugin:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li><strong>Looks at the image<\/strong> using an AI vision model and generates a text description.<\/li>\n\n\n\n<li><strong>Creates a draft post<\/strong> \u2014 title and content \u2014 based on that image description.<\/li>\n\n\n\n<li><strong>Updates the draft post<\/strong>, setting the original image as the post&#8217;s featured image.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\">The interesting part isn&#8217;t <em>what<\/em> it does, though. It&#8217;s <em>how<\/em> it&#8217;s built. Under the hood, Photo to Post is three composable <a href=\"https:\/\/make.wordpress.org\/core\/2025\/11\/10\/abilities-api-in-wordpress-6-9\/\">WordPress Abilities<\/a> that call the <a href=\"https:\/\/github.com\/WordPress\/php-ai-client\">WordPress AI Client<\/a>:<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Ability<\/th><th>What it does<\/th><th>AI capability<\/th><\/tr><\/thead><tbody><tr><td><code>describe-image<\/code><\/td><td>Image URL <span aria-hidden=\"true\" class=\"wp-exclude-emoji\">\u2192<\/span> text description<\/td><td>Vision (image input)<\/td><\/tr><tr><td><code>generate-post-from-description<\/code><\/td><td>Description <span aria-hidden=\"true\" class=\"wp-exclude-emoji\">\u2192<\/span> post title + content<\/td><td>Text generation<\/td><\/tr><tr><td><code>create-post-from-photo<\/code><\/td><td>Orchestrates the two above, then creates the post<\/td><td>No AI in this one, but it orchestrates the others<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">That third ability is an example of <strong>ability composition<\/strong> \u2014 one ability calling other abilities via <code>WP_Ability::execute()<\/code>. This is one of the more exciting aspects of the Abilities API compared to traditional methods of building functionality in WordPress.<\/p>\n\n\n\n<h2 id=\"before-you-start\" class=\"wp-block-heading\">Before you start<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">To be able to run the code for this tutorial, you\u2019re going to need a few things on your local computer:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>A local WordPress environment running version 7.0 or later on PHP 8.1 or higher<\/li>\n\n\n\n<li>The latest version of <a href=\"https:\/\/getcomposer.org\/\">Composer<\/a> and <a href=\"https:\/\/nodejs.org\/en\">Node.js<\/a> is installed<\/li>\n\n\n\n<li>API credentials for an AI provider that supports a vision-capable model (more on this later)<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">To make life easier (and this tutorial shorter), you\u2019ll be building on top of a starter plugin available here: <a href=\"https:\/\/github.com\/wptrainingteam\/wp-ai-workshop-demo\/releases\">https:\/\/github.com\/wptrainingteam\/wp-ai-workshop-demo\/releases<\/a>. This plugin already contains the foundation of the feature set, so you can just build out the AI functionality.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Download the latest version of the plugin (<a href=\"https:\/\/github.com\/wptrainingteam\/wp-ai-workshop-demo\/releases\/tag\/1.1.1\">1.1.1<\/a>), install and activate it in your local WordPress development environment.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Once activated, the plugin adds a new admin page under <strong>Tools<\/strong> called <strong>WP AI Workshop Demo<\/strong>, which displays a form for managing the Photo to Post process.<\/p>\n\n\n\n<figure data-wp-context=\"{&quot;imageId&quot;:&quot;6a7458e681c49&quot;}\" data-wp-interactive=\"core\/image\" data-wp-key=\"6a7458e681c49\" class=\"wp-block-image size-full wp-lightbox-container\"><img loading=\"lazy\" decoding=\"async\" width=\"3200\" height=\"1800\" data-wp-class--hide=\"state.isContentHidden\" data-wp-class--show=\"state.isContentVisible\" data-wp-init=\"callbacks.setButtonStyles\" data-wp-on--click=\"actions.showLightbox\" data-wp-on--load=\"callbacks.setButtonStyles\" data-wp-on--pointerdown=\"actions.preloadImage\" data-wp-on--pointerenter=\"actions.preloadImageWithDelay\" data-wp-on--pointerleave=\"actions.cancelPreload\" data-wp-on-window--resize=\"callbacks.setButtonStyles\" src=\"https:\/\/developer.wordpress.org\/news\/files\/2026\/07\/wordpress-ai-demo-settings-scaled.png\" alt=\"WP AI Workshop Demo settings page\" class=\"wp-image-6325\" srcset=\"https:\/\/developer.wordpress.org\/news\/files\/2026\/07\/wordpress-ai-demo-settings-scaled.png 3200w, https:\/\/developer.wordpress.org\/news\/files\/2026\/07\/wordpress-ai-demo-settings-300x169.png 300w, https:\/\/developer.wordpress.org\/news\/files\/2026\/07\/wordpress-ai-demo-settings-768x432.png 768w, https:\/\/developer.wordpress.org\/news\/files\/2026\/07\/wordpress-ai-demo-settings-1024x576.png 1024w, https:\/\/developer.wordpress.org\/news\/files\/2026\/07\/wordpress-ai-demo-settings-1536x864.png 1536w, https:\/\/developer.wordpress.org\/news\/files\/2026\/07\/wordpress-ai-demo-settings-2048x1152.png 2048w\" sizes=\"auto, (max-width: 3200px) 100vw, 3200px\" \/><button\n\t\t\tclass=\"lightbox-trigger\"\n\t\t\ttype=\"button\"\n\t\t\taria-haspopup=\"dialog\"\n\t\t\tdata-wp-bind--aria-label=\"state.thisImage.triggerButtonAriaLabel\"\n\t\t\tdata-wp-init=\"callbacks.initTriggerButton\"\n\t\t\tdata-wp-on--click=\"actions.showLightbox\"\n\t\t\tdata-wp-style--right=\"state.thisImage.buttonRight\"\n\t\t\tdata-wp-style--top=\"state.thisImage.buttonTop\"\n\t\t>\n\t\t\t<svg xmlns=\"http:\/\/www.w3.org\/2000\/svg\" width=\"12\" height=\"12\" fill=\"none\" viewBox=\"0 0 12 12\">\n\t\t\t\t<path fill=\"#fff\" d=\"M2 0a2 2 0 0 0-2 2v2h1.5V2a.5.5 0 0 1 .5-.5h2V0H2Zm2 10.5H2a.5.5 0 0 1-.5-.5V8H0v2a2 2 0 0 0 2 2h2v-1.5ZM8 12v-1.5h2a.5.5 0 0 0 .5-.5V8H12v2a2 2 0 0 1-2 2H8Zm2-12a2 2 0 0 1 2 2v2h-1.5V2a.5.5 0 0 0-.5-.5H8V0h2Z\" \/>\n\t\t\t<\/svg>\n\t\t<\/button><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">It uses the <a href=\"https:\/\/developer.wordpress.org\/news\/2026\/01\/how-to-use-dataform-to-create-plugin-settings-pages\/\">WordPress DataForm package<\/a> to render the form, but currently doesn\u2019t provide any functionality.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Since we&#8217;re going to be building out the functionality of this tutorial, it&#8217;ll be helpful to install the composer and npm dependencies now. Inside the plugin directory, run:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code lang=\"bash\" class=\"language-bash\">composer install\nnpm install<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Open up the main plugin file, <code>wp-ai-workshop-demo.php<\/code>, and you\u2019ll see a couple of comments marked as <code>TODO<\/code>, like this one on line 29:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code lang=\"php\" class=\"language-php\">\/\/ TODO: Register the ability category and the three Photo to Post ability hooks.<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">This indicates the different areas around the plugin where you\u2019ll build out the functionality. Every time this tutorial asks you to add some code in a file, look for the specific <code>TODO<\/code> to replace.<\/p>\n\n\n\n<h2 id=\"connecting-your-site-to-your-ai-provider\" class=\"wp-block-heading\">Connecting your site to your AI provider<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">To enable your WordPress site to use generative AI via the AI Client, you need to connect it to an AI provider. In this case, the process of reading the photo, generating its description, and then generating the post content all use the AI Client.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Since WordPress 7.0, this is possible via the <a href=\"https:\/\/make.wordpress.org\/core\/2026\/05\/14\/wordpress-7-0-field-guide\/#ai-connectors-screen\">new Connectors screen<\/a>, which can be found in the Settings menu. Powered by the <a href=\"https:\/\/make.wordpress.org\/core\/2026\/03\/18\/introducing-the-connectors-api-in-wordpress-7-0\/\">Connectors API<\/a>, the Connectors screen provides a WordPress site admin with a single interface for storing external API keys.<\/p>\n\n\n\n<figure data-wp-context=\"{&quot;imageId&quot;:&quot;6a7458e6827a7&quot;}\" data-wp-interactive=\"core\/image\" data-wp-key=\"6a7458e6827a7\" class=\"wp-block-image size-full wp-lightbox-container\"><img loading=\"lazy\" decoding=\"async\" width=\"3200\" height=\"1800\" data-wp-class--hide=\"state.isContentHidden\" data-wp-class--show=\"state.isContentVisible\" data-wp-init=\"callbacks.setButtonStyles\" data-wp-on--click=\"actions.showLightbox\" data-wp-on--load=\"callbacks.setButtonStyles\" data-wp-on--pointerdown=\"actions.preloadImage\" data-wp-on--pointerenter=\"actions.preloadImageWithDelay\" data-wp-on--pointerleave=\"actions.cancelPreload\" data-wp-on-window--resize=\"callbacks.setButtonStyles\" src=\"https:\/\/developer.wordpress.org\/news\/files\/2026\/07\/wordpress-ai-demo.connectors-scaled.png\" alt=\"WordPress Connectors page\" class=\"wp-image-6327\" srcset=\"https:\/\/developer.wordpress.org\/news\/files\/2026\/07\/wordpress-ai-demo.connectors-scaled.png 3200w, https:\/\/developer.wordpress.org\/news\/files\/2026\/07\/wordpress-ai-demo.connectors-300x169.png 300w, https:\/\/developer.wordpress.org\/news\/files\/2026\/07\/wordpress-ai-demo.connectors-768x432.png 768w, https:\/\/developer.wordpress.org\/news\/files\/2026\/07\/wordpress-ai-demo.connectors-1024x576.png 1024w, https:\/\/developer.wordpress.org\/news\/files\/2026\/07\/wordpress-ai-demo.connectors-1536x864.png 1536w, https:\/\/developer.wordpress.org\/news\/files\/2026\/07\/wordpress-ai-demo.connectors-2048x1152.png 2048w\" sizes=\"auto, (max-width: 3200px) 100vw, 3200px\" \/><button\n\t\t\tclass=\"lightbox-trigger\"\n\t\t\ttype=\"button\"\n\t\t\taria-haspopup=\"dialog\"\n\t\t\tdata-wp-bind--aria-label=\"state.thisImage.triggerButtonAriaLabel\"\n\t\t\tdata-wp-init=\"callbacks.initTriggerButton\"\n\t\t\tdata-wp-on--click=\"actions.showLightbox\"\n\t\t\tdata-wp-style--right=\"state.thisImage.buttonRight\"\n\t\t\tdata-wp-style--top=\"state.thisImage.buttonTop\"\n\t\t>\n\t\t\t<svg xmlns=\"http:\/\/www.w3.org\/2000\/svg\" width=\"12\" height=\"12\" fill=\"none\" viewBox=\"0 0 12 12\">\n\t\t\t\t<path fill=\"#fff\" d=\"M2 0a2 2 0 0 0-2 2v2h1.5V2a.5.5 0 0 1 .5-.5h2V0H2Zm2 10.5H2a.5.5 0 0 1-.5-.5V8H0v2a2 2 0 0 0 2 2h2v-1.5ZM8 12v-1.5h2a.5.5 0 0 0 .5-.5V8H12v2a2 2 0 0 1-2 2H8Zm2-12a2 2 0 0 1 2 2v2h-1.5V2a.5.5 0 0 0-.5-.5H8V0h2Z\" \/>\n\t\t\t<\/svg>\n\t\t<\/button><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Previously, any plugin that added an external connection to WordPress required a place for the WordPress site admin to store the API key to enable the connection. Some of the more common examples of this include:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Akismet for anti-spam<\/li>\n\n\n\n<li>Mailchimp for WordPress for adding Mailchimp email support<\/li>\n\n\n\n<li>Site Kit by Google for Google Analytics information<\/li>\n\n\n\n<li>WooCommerce payment gateway extensions (e.g., Stripe, PayPal, Authorize.net)<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">Plugin developers typically had to manage storing the API key themselves, usually on a separate Settings page. So if you had 4 different plugins that stored API keys, that meant you had 4 different places to manage them.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The Connectors API and screen bring all that information into one place. Developers can register a Connector for the service their plugin provides, and the Connector appears on the Connectors page, where users can enter and store their API key.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">In the context of AI providers, plugins exist to support the three major frontier providers: <a href=\"https:\/\/wordpress.org\/plugins\/ai-provider-for-openai\/\"><strong>OpenAI<\/strong><\/a>, <a href=\"https:\/\/wordpress.org\/plugins\/ai-provider-for-anthropic\/\"><strong>Anthropic<\/strong><\/a>, and <a href=\"https:\/\/wordpress.org\/plugins\/ai-provider-for-google\/\"><strong>Google<\/strong><\/a><strong>.<\/strong> From the Connectors page, you can install the plugin for the provider of your choice<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Once installed, you can enter and save the API key to enable the AI Client functionality.<\/p>\n\n\n\n<figure data-wp-context=\"{&quot;imageId&quot;:&quot;6a7458e683211&quot;}\" data-wp-interactive=\"core\/image\" data-wp-key=\"6a7458e683211\" class=\"wp-block-image size-full wp-lightbox-container\"><img loading=\"lazy\" decoding=\"async\" width=\"3200\" height=\"1800\" data-wp-class--hide=\"state.isContentHidden\" data-wp-class--show=\"state.isContentVisible\" data-wp-init=\"callbacks.setButtonStyles\" data-wp-on--click=\"actions.showLightbox\" data-wp-on--load=\"callbacks.setButtonStyles\" data-wp-on--pointerdown=\"actions.preloadImage\" data-wp-on--pointerenter=\"actions.preloadImageWithDelay\" data-wp-on--pointerleave=\"actions.cancelPreload\" data-wp-on-window--resize=\"callbacks.setButtonStyles\" src=\"https:\/\/developer.wordpress.org\/news\/files\/2026\/07\/wordpress-ai-demo-connectors-api-key-scaled.png\" alt=\"Anthropic AI Provider AI API key\" class=\"wp-image-6329\" srcset=\"https:\/\/developer.wordpress.org\/news\/files\/2026\/07\/wordpress-ai-demo-connectors-api-key-scaled.png 3200w, https:\/\/developer.wordpress.org\/news\/files\/2026\/07\/wordpress-ai-demo-connectors-api-key-300x169.png 300w, https:\/\/developer.wordpress.org\/news\/files\/2026\/07\/wordpress-ai-demo-connectors-api-key-768x432.png 768w, https:\/\/developer.wordpress.org\/news\/files\/2026\/07\/wordpress-ai-demo-connectors-api-key-1024x576.png 1024w, https:\/\/developer.wordpress.org\/news\/files\/2026\/07\/wordpress-ai-demo-connectors-api-key-1536x864.png 1536w, https:\/\/developer.wordpress.org\/news\/files\/2026\/07\/wordpress-ai-demo-connectors-api-key-2048x1152.png 2048w\" sizes=\"auto, (max-width: 3200px) 100vw, 3200px\" \/><button\n\t\t\tclass=\"lightbox-trigger\"\n\t\t\ttype=\"button\"\n\t\t\taria-haspopup=\"dialog\"\n\t\t\tdata-wp-bind--aria-label=\"state.thisImage.triggerButtonAriaLabel\"\n\t\t\tdata-wp-init=\"callbacks.initTriggerButton\"\n\t\t\tdata-wp-on--click=\"actions.showLightbox\"\n\t\t\tdata-wp-style--right=\"state.thisImage.buttonRight\"\n\t\t\tdata-wp-style--top=\"state.thisImage.buttonTop\"\n\t\t>\n\t\t\t<svg xmlns=\"http:\/\/www.w3.org\/2000\/svg\" width=\"12\" height=\"12\" fill=\"none\" viewBox=\"0 0 12 12\">\n\t\t\t\t<path fill=\"#fff\" d=\"M2 0a2 2 0 0 0-2 2v2h1.5V2a.5.5 0 0 1 .5-.5h2V0H2Zm2 10.5H2a.5.5 0 0 1-.5-.5V8H0v2a2 2 0 0 0 2 2h2v-1.5ZM8 12v-1.5h2a.5.5 0 0 0 .5-.5V8H12v2a2 2 0 0 1-2 2H8Zm2-12a2 2 0 0 1 2 2v2h-1.5V2a.5.5 0 0 0-.5-.5H8V0h2Z\" \/>\n\t\t\t<\/svg>\n\t\t<\/button><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">If you prefer to use AI models other than those from OpenAI, Anthropic, or Google, plugins exist for several other providers, including <a href=\"https:\/\/wordpress.org\/plugins\/ai-provider-for-ollama\/\">Ollama<\/a>, <a href=\"https:\/\/wordpress.org\/plugins\/ai-provider-for-openrouter\/\">OpenRouter<\/a>, and <a href=\"https:\/\/wordpress.org\/plugins\/ai-provider-for-mistral\/\">Mistral<\/a>. Just make sure your provider uses a <strong>vision-capable<\/strong> model for the functionality you\u2019ll build in this tutorial.<\/p>\n\n\n\n<h2 id=\"building-the-plugin-functionality\" class=\"wp-block-heading\">Building the plugin functionality<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">With your AI provider connected, you can now start building out all the plugin features.<\/p>\n\n\n\n<h2 id=\"step-1-register-the-abilities\" class=\"wp-block-heading\">Step 1: Register the abilities<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">An ability is just a registered description of <em>something your plugin can do<\/em>: a label, a description, an input schema, an output schema, a permission check, and a callback that does the actual work. You\u2019ll perform all your Abilities registration inside the <code>includes\/abilities.php<\/code>. file.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">First, you need to register a category to group your abilities using the <code>wp_register_ability_category<\/code> function. Inside the <code>wp_ai_workshop_demo_register_ability_categories<\/code> function, register the category:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code lang=\"php\" class=\"language-php\">wp_register_ability_category(\n\t'wp-ai-workshop-demo',\n\tarray(\n\t\t'label'       =&gt; __( 'WP AI Workshop Demo', 'wp-ai-workshop-demo' ),\n\t\t'description' =&gt; __( 'Abilities for the WP AI Workshop Demo.', 'wp-ai-workshop-demo' ),\n\t)\n);<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Then you can register the first ability, <code>describe-image<\/code>, using the <code>wp_register_ability<\/code> function. Notice how much of this is just <em>describing<\/em> the ability \u2014 the input and output schemas tell anything that consumes the ability exactly what to send in and what to expect back. Add this to the <code>wp_ai_workshop_demo_register_describe_image_ability<\/code> function:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code lang=\"php\" class=\"language-php\">wp_register_ability(\n\t'wp-ai-workshop-demo\/describe-image',\n\tarray(\n\t\t'label'               =&gt; __( 'Describe an image via AI', 'wp-ai-workshop-demo' ),\n\t\t'description'         =&gt; __( 'Given an image URL, use AI vision to produce a detailed text description of the image.', 'wp-ai-workshop-demo' ),\n\t\t'category'            =&gt; 'wp-ai-workshop-demo',\n\t\t'input_schema'        =&gt; array(\n\t\t\t'type'       =&gt; 'object',\n\t\t\t'properties' =&gt; array(\n\t\t\t\t'image_url' =&gt; array(\n\t\t\t\t\t'type'        =&gt; 'string',\n\t\t\t\t\t'description' =&gt; 'The URL of the image to describe.',\n\t\t\t\t),\n\t\t\t),\n\t\t\t'required'   =&gt; array( 'image_url' ),\n\t\t),\n\t\t'output_schema'       =&gt; array(\n\t\t\t'type'       =&gt; 'object',\n\t\t\t'properties' =&gt; array(\n\t\t\t\t'description' =&gt; array(\n\t\t\t\t\t'type'        =&gt; 'string',\n\t\t\t\t\t'description' =&gt; 'A detailed description of the image.',\n\t\t\t\t),\n\t\t\t),\n\t\t\t'required'   =&gt; array( 'description' ),\n\t\t),\n\t\t'execute_callback'    =&gt; 'wp_ai_workshop_demo_describe_image',\n\t\t'permission_callback' =&gt; function () {\n\t\t\treturn current_user_can( 'edit_posts' );\n\t\t},\n\t\t'meta'                =&gt; array(\n\t\t\t'show_in_rest' =&gt; true,\n\t\t),\n\t)\n);<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The <code>generate-post-from-description<\/code> and <code>create-post-from-photo<\/code> abilities follow exactly the same pattern \u2014 only the schemas and callbacks change.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">First, the <code>wp_ai_workshop_demo_register_generate_post_from_description_ability<\/code> function:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code lang=\"php\" class=\"language-php\">wp_register_ability(\n\t'wp-ai-workshop-demo\/generate-post-from-description',\n\tarray(\n\t\t'label'               =&gt; __( 'Generate post copy from a description', 'wp-ai-workshop-demo' ),\n\t\t'description'         =&gt; __( 'Given a description (and optional tone\/angle), generate a WordPress post title and body content.', 'wp-ai-workshop-demo' ),\n\t\t'category'            =&gt; 'wp-ai-workshop-demo',\n\t\t'input_schema'        =&gt; array(\n\t\t\t'type'       =&gt; 'object',\n\t\t\t'properties' =&gt; array(\n\t\t\t\t'description' =&gt; array(\n\t\t\t\t\t'type'        =&gt; 'string',\n\t\t\t\t\t'description' =&gt; 'The source description to base the post on.',\n\t\t\t\t),\n\t\t\t\t'prompt'      =&gt; array(\n\t\t\t\t\t'type'        =&gt; 'string',\n\t\t\t\t\t'description' =&gt; 'Optional tone, angle, or extra guidance for the post.',\n\t\t\t\t),\n\t\t\t),\n\t\t\t'required'   =&gt; array( 'description' ),\n\t\t),\n\t\t'output_schema'       =&gt; array(\n\t\t\t'type'       =&gt; 'object',\n\t\t\t'properties' =&gt; array(\n\t\t\t\t'title'   =&gt; array(\n\t\t\t\t\t'type'        =&gt; 'string',\n\t\t\t\t\t'description' =&gt; 'The generated post title.',\n\t\t\t\t),\n\t\t\t\t'content' =&gt; array(\n\t\t\t\t\t'type'        =&gt; 'string',\n\t\t\t\t\t'description' =&gt; 'The generated post content in Block Editor markup.',\n\t\t\t\t),\n\t\t\t),\n\t\t\t'required'   =&gt; array( 'title', 'content' ),\n\t\t),\n\t\t'execute_callback'    =&gt; 'wp_ai_workshop_demo_generate_post_from_description',\n\t\t'permission_callback' =&gt; function () {\n\t\t\treturn current_user_can( 'edit_posts' );\n\t\t},\n\t\t'meta'                =&gt; array(\n\t\t\t'show_in_rest' =&gt; true,\n\t\t),\n\t)\n);<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Followed by the <code>wp_ai_workshop_demo_register_create_post_from_photo_ability<\/code> function:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code lang=\"php\" class=\"language-php\">wp_register_ability(\n\t'wp-ai-workshop-demo\/create-post-from-photo',\n\tarray(\n\t\t'label'               =&gt; __( 'Create a post from a photo via AI', 'wp-ai-workshop-demo' ),\n\t\t'description'         =&gt; __( 'Given an image URL, describe the image, write a post about it, and create a draft post using the image as the featured image.', 'wp-ai-workshop-demo' ),\n\t\t'category'            =&gt; 'wp-ai-workshop-demo',\n\t\t'input_schema'        =&gt; array(\n\t\t\t'type'       =&gt; 'object',\n\t\t\t'properties' =&gt; array(\n\t\t\t\t'image_url' =&gt; array(\n\t\t\t\t\t'type'        =&gt; 'string',\n\t\t\t\t\t'description' =&gt; 'The URL of the image to turn into a post.',\n\t\t\t\t),\n\t\t\t\t'prompt'    =&gt; array(\n\t\t\t\t\t'type'        =&gt; 'string',\n\t\t\t\t\t'description' =&gt; 'Optional tone, angle, or extra guidance for the post.',\n\t\t\t\t),\n\t\t\t),\n\t\t\t'required'   =&gt; array( 'image_url' ),\n\t\t),\n\t\t'output_schema'       =&gt; array(\n\t\t\t'type'       =&gt; 'object',\n\t\t\t'properties' =&gt; array(\n\t\t\t\t'message' =&gt; array(\n\t\t\t\t\t'type'        =&gt; 'string',\n\t\t\t\t\t'description' =&gt; 'A status message describing the result.',\n\t\t\t\t),\n\t\t\t\t'post_id' =&gt; array(\n\t\t\t\t\t'type'        =&gt; 'integer',\n\t\t\t\t\t'description' =&gt; 'The ID of the newly created post.',\n\t\t\t\t),\n\t\t\t),\n\t\t\t'required'   =&gt; array( 'message' ),\n\t\t),\n\t\t'execute_callback'    =&gt; 'wp_ai_workshop_demo_create_post_from_photo',\n\t\t'permission_callback' =&gt; function () {\n\t\t\treturn current_user_can( 'edit_posts' );\n\t\t},\n\t\t'meta'                =&gt; array(\n\t\t\t'show_in_rest' =&gt; true,\n\t\t),\n\t)\n);<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The key thing to internalize here is the Ability shape: <strong>label, description, schemas, permission, callback.<\/strong> Once it clicks, you&#8217;ll be registering abilities in your sleep.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">So why bother describing everything so formally? The <code>label<\/code>, <code>description<\/code>, <code>input_schema,<\/code>and <code>output_schema<\/code> are what let the REST API, other developers, and AI agents all understand your ability without you writing any additional code. You describe it once; everything that wants to use it instantly understands what it\u2019s called, what it does, what inputs it expects, and what outputs it will return.<\/p>\n\n\n\n<h2 id=\"step-2-hook-the-abilities-in\" class=\"wp-block-heading\">Step 2: Hook the abilities in<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Like most things in WordPress, Ability registration occurs via dedicated action hooks: <code>wp_abilities_api_categories_init<\/code> and <code>wp_abilities_api_init<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">In the main plugin file, <code>wp-ai-workshop-demo.php<\/code>, wire up the functions from the <code>includes\/abilities.php<\/code> file to the relevant hook:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code lang=\"php\" class=\"language-php\">add_action( 'wp_abilities_api_categories_init', 'wp_ai_workshop_demo_register_ability_categories' );\nadd_action( 'wp_abilities_api_init', 'wp_ai_workshop_demo_register_describe_image_ability' );\nadd_action( 'wp_abilities_api_init', 'wp_ai_workshop_demo_register_generate_post_from_description_ability' );\nadd_action( 'wp_abilities_api_init', 'wp_ai_workshop_demo_register_create_post_from_photo_ability' );<\/code><\/pre>\n\n\n\n<h2 id=\"step-3-check-your-work-over-rest\" class=\"wp-block-heading\">Step 3: Check your work over REST<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Here&#8217;s one of my favorite things about the Abilities API \u2014 the moment you register an ability with <code>'show_in_rest' =&gt; true<\/code>, it&#8217;s exposed over the REST API to authenticated users, no extra code needed. Make sure your local site has Permalinks set to anything <em>other<\/em> than &#8220;Plain&#8221;, or the REST endpoints won&#8217;t resolve.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">To test this, create an <a href=\"https:\/\/developer.wordpress.org\/advanced-administration\/security\/application-passwords\/\">Application Password<\/a> for your admin user, then test it using something like <code>curl<\/code> in the terminal:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code lang=\"bash\" class=\"language-bash\">curl -u 'USERNAME:APPLICATION_PASSWORD' https:\/\/yoursite.local\/wp-json\/wp-abilities\/v1\/abilities<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">You should see a JSON response which includes the three core WordPress abilities, followed by your three <code>wp-ai-workshop-demo\/*<\/code> abilities.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">This is incredibly reassuring as a checkpoint. You haven&#8217;t written any internal functionality for these Abilities yet, but you already have three discoverable, schema-described endpoints.<\/p>\n\n\n\n<h2 id=\"step-4-describe-the-image-your-first-ai-call\" class=\"wp-block-heading\">Step 4: Describe the image (your first AI call)<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Now for the fun part. You will need to implement the functionality for all Ability execute callback functions, which is where you\u2019ll connect to and use the AI providers.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Start with <code>wp_ai_workshop_demo_describe_image<\/code>, which executes the <code>wp-ai-workshop-demo\/describe-image<\/code> Ability. This happens in the <code>includes\/vision.php<\/code>. File, and it\u2019s where you make your first call to the WordPress AI Client.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code lang=\"php\" class=\"language-php\">$image_url = $arguments['image_url'];\n\n\/\/ Vision-capable providers (e.g. Anthropic) require the image to be sent\n\/\/ inline (base64), not as a remote URL, so fetch it and build a data URI.\n\/\/ A data URI is also accepted by URL-friendly providers such as OpenAI,\n\/\/ which keeps this ability portable across providers.\n$data_uri = wp_ai_workshop_demo_image_url_to_data_uri( $image_url );\nif ( is_wp_error( $data_uri ) ) {\n\treturn $data_uri;\n}\n\n$prompt  = 'Describe this image in detail for someone who cannot see it. ';\n$prompt .= 'Focus on the main subject, the setting, the mood, notable colours, ';\n$prompt .= 'and any details that would help someone write an engaging blog post about it.';\n\n$description = wp_ai_client_prompt()\n\t-&gt;with_text( $prompt )\n\t-&gt;with_file( $data_uri )\n\t-&gt;generate_text();\n\nif ( is_wp_error( $description ) ) {\n\treturn $description;\n}\n\nreturn array(\n\t'description' =&gt; trim( $description ),\n);<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Look at how readable that AI Client call is. <code>wp_ai_client_prompt()<\/code> gives you a fluent builder: add your prompt text, attach a file, ask for text back. And because the AI Client is provider-agnostic, this exact code works whether the user configured OpenAI, Anthropic, or Google.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">You&#8217;ll also need to add the <code>wp_ai_workshop_demo_image_url_to_data_uri()<\/code> helper function. This fetches the remote image with <code>wp_remote_get()<\/code> and base64-encodes it into a data URI to send to the AI model.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Information:<\/strong> Generally, developers like to put helper functions like this in a separate file in the plugin. For the purposes of this tutorial, you can just add any helper functions at the bottom of the relevant file.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code lang=\"php\" class=\"language-php\">\/**\n * Fetch a remote image and return it as a base64-encoded data URI.\n *\n * @param string $image_url The URL of the image to fetch.\n * @return string|WP_Error The data URI on success, or WP_Error on failure.\n *\/\nfunction wp_ai_workshop_demo_image_url_to_data_uri( $image_url ) {\n\t$response = wp_remote_get(\n\t\t$image_url,\n\t\tarray(\n\t\t\t'timeout'    =&gt; 30,\n\t\t\t'user-agent' =&gt; 'WP-AI-Workshop-Demo\/1.0; ' . home_url( '\/' ),\n\t\t)\n\t);\n\tif ( is_wp_error( $response ) ) {\n\t\treturn $response;\n\t}\n\n\t$code = (int) wp_remote_retrieve_response_code( $response );\n\tif ( 200 !== $code ) {\n\t\treturn new WP_Error(\n\t\t\t'image_fetch_failed',\n\t\t\tsprintf( 'Could not fetch the image (HTTP %d).', $code )\n\t\t);\n\t}\n\n\t$body = wp_remote_retrieve_body( $response );\n\tif ( '' === $body ) {\n\t\treturn new WP_Error( 'image_fetch_empty', 'The fetched image was empty.' );\n\t}\n\n\t$mime = wp_remote_retrieve_header( $response, 'content-type' );\n\tif ( ! is_string( $mime ) || 0 !== strpos( $mime, 'image\/' ) ) {\n\t\t$filetype = wp_check_filetype( $image_url );\n\t\t$mime     = ! empty( $filetype['type'] ) ? $filetype['type'] : 'image\/jpeg';\n\t}\n\n\treturn 'data:' . $mime . ';base64,' . base64_encode( $body );\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">There are two things to note here:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><code>wp_ai_client_prompt()-&gt;generate_text()<\/code> <em>returns<\/em> a <code>WP_Error<\/code> on failure \u2014 it doesn&#8217;t throw an exception. The AI Client&#8217;s WordPress wrapper catches exceptions for you and hands back a <code>WP_Error<\/code>. So always check for <code>is_wp_error()<\/code>.<\/li>\n\n\n\n<li>Vision providers such as Anthropic want the image sent <strong>inline as base64<\/strong>, not as a remote URL. That&#8217;s why you fetch it and build a data URI. Sending a URL works on some providers and fails on others \u2014 the data URI makes it the most portable.<\/li>\n<\/ul>\n\n\n\n<h2 id=\"step-5-generate-the-post-copy\" class=\"wp-block-heading\">Step 5: Generate the post copy<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The next Ability execute callback to update, <code>wp_ai_workshop_demo_generate_post_from_description<\/code>, is inside <code>includes\/content.php<\/code>. This function makes a second AI call that turns the description into a post title and body. Notice how the prompt asks the AI to return a specific JSON object, making it easier to return the title and content.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code lang=\"php\" class=\"language-php\">$description = $arguments['description'];\n$guidance    = ! empty( $arguments['prompt'] ) ? trim( $arguments['prompt'] ) : '';\n\n$prompt = 'Write an engaging WordPress blog post inspired by the following image description.';\nif ( '' !== $guidance ) {\n\t$prompt .= ' Tone, angle, or extra guidance: ' . $guidance . '.';\n}\n$prompt .= ' Image description: ' . $description;\n$prompt .= ' Respond with a single JSON object containing exactly two keys: ';\n$prompt .= '\"title\" (a short, compelling post title as plain text) and ';\n$prompt .= '\"content\" (the full post body using valid WordPress Block Editor markup). ';\n$prompt .= 'Do not wrap the response in markdown code fences.';\n\n$text = wp_ai_client_prompt( $prompt )-&gt;generate_text();\n\nif ( is_wp_error( $text ) ) {\n\treturn $text;\n}\n\n$data = wp_ai_workshop_demo_decode_json_response( $text );\n\nif ( ! is_array( $data ) || empty( $data['title'] ) || empty( $data['content'] ) ) {\n\treturn new WP_Error(\n\t\t'post_copy_parse_failed',\n\t\t'The AI response could not be parsed into a title and content.'\n\t);\n}\n\nreturn array(\n\t'title'   =&gt; trim( $data['title'] ),\n\t'content' =&gt; trim( $data['content'] ),\n);<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">You&#8217;ll also add a <code>wp_ai_workshop_demo_decode_json_response()<\/code> helper function that deliberately strips any markdown code fences the model might wrap around the JSON before decoding.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code lang=\"php\" class=\"language-php\">\/**\n * Decode a JSON object from an AI text response, tolerating markdown fences.\n *\n * @param string $text The raw text returned by the AI.\n * @return array|null Decoded associative array, or null if no JSON could be parsed.\n *\/\nfunction wp_ai_workshop_demo_decode_json_response( $text ) {\n\t$text = trim( $text );\n\n\t$text = preg_replace( '\/^\\s*```(?:[a-z0-9_-]+)?\\s*\/i', '', $text );\n\t$text = preg_replace( '\/\\s*```\\s*$\/i', '', $text );\n\t$text = trim( $text );\n\n\t$data = json_decode( $text, true );\n\tif ( is_array( $data ) ) {\n\t\treturn $data;\n\t}\n\n\tif ( preg_match( '\/\\{.*\\}\/s', $text, $matches ) ) {\n\t\t$data = json_decode( $matches[0], true );\n\t\tif ( is_array( $data ) ) {\n\t\t\treturn $data;\n\t\t}\n\t}\n\n\treturn null;\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">You might wonder why you\u2019re adding this when the prompt explicitly says not to. Here&#8217;s the thing about asking a language model for clean JSON: it&#8217;ll mostly comply, until it doesn\u2019t. AI models are not deterministic, meaning they don\u2019t follow a fixed set of rules. Rather, they recognize patterns and calculate the mathematical likelihood of the next output. Because of this, asking an AI the exact same question can produce different responses each time. So, adding a defensive check here and parsing the AI&#8217;s data ensures you get the correct response type every time.<\/p>\n\n\n\n<h2 id=\"step-6-compose-the-abilities-the-good-bit\" class=\"wp-block-heading\">Step 6: Compose the abilities (the good bit)<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Now for the really interesting part. Inside the <code>includes\/post.php<\/code> file is the <code>wp_ai_workshop_demo_create_post_from_photo<\/code>, which is the core of the plugin functionality. Here, this orchestrator function can call the other two abilities.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code lang=\"php\" class=\"language-php\">$image_url = $arguments['image_url'];\n$prompt    = isset( $arguments['prompt'] ) ? $arguments['prompt'] : '';\n\n\/\/ Step 1: Describe the image (ability composition).\n$describe_ability = wp_get_ability( 'wp-ai-workshop-demo\/describe-image' );\nif ( ! $describe_ability instanceof WP_Ability ) {\n\treturn array( 'message' =&gt; 'Post creation failed: the describe-image ability is not available.' );\n}\n$description_result = $describe_ability-&gt;execute( array( 'image_url' =&gt; $image_url ) );\nif ( is_wp_error( $description_result ) ) {\n\treturn array( 'message' =&gt; 'Post creation failed while describing the image: ' . $description_result-&gt;get_error_message() );\n}\n\n\/\/ Step 2: Generate the post title and content (ability composition).\n$generate_ability = wp_get_ability( 'wp-ai-workshop-demo\/generate-post-from-description' );\nif ( ! $generate_ability instanceof WP_Ability ) {\n\treturn array( 'message' =&gt; 'Post creation failed: the generate-post-from-description ability is not available.' );\n}\n$copy_result = $generate_ability-&gt;execute(\n\tarray(\n\t\t'description' =&gt; $description_result['description'],\n\t\t'prompt'      =&gt; $prompt,\n\t)\n);\nif ( is_wp_error( $copy_result ) ) {\n\treturn array( 'message' =&gt; 'Post creation failed while writing the post: ' . $copy_result-&gt;get_error_message() );\n}\n\n\/\/ Step 3: Create the draft post and set the featured image.\nreturn wp_ai_workshop_demo_create_post(\n\t$copy_result['title'],\n\t$copy_result['content'],\n\t$image_url\n);<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Do you see what&#8217;s happening? <code>wp_get_ability()<\/code> fetches an ability by name, and <code>-&gt;execute()<\/code> runs it. Your orchestrator never touches the AI Client directly; it just composes two abilities that already know how to do their jobs, then hands off to a <code>wp_ai_workshop_demo_create_post()<\/code> helper that calls <code>wp_insert_post()<\/code> to create the post and sideloads the featured image with <code>media_sideload_image()<\/code>helper. Now would be a good time to add these helper functions.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code lang=\"php\" class=\"language-php\">\/**\n * Create a draft post and set its featured image from an image URL.\n *\n * @param string $title The Post title.\n * @param string $content The Post content.\n * @param string $image_url The external image url.\n *\/\nfunction wp_ai_workshop_demo_create_post( $title, $content, $image_url ) {\n\t$post_id = wp_insert_post(\n\t\tarray(\n\t\t\t'post_title'   =&gt; sanitize_text_field( $title ),\n\t\t\t'post_content' =&gt; $content,\n\t\t\t'post_status'  =&gt; 'draft',\n\t\t\t'post_type'    =&gt; 'post',\n\t\t)\n\t);\n\n\tif ( is_wp_error( $post_id ) || 0 === $post_id ) {\n\t\treturn array( 'message' =&gt; 'Post creation failed.' );\n\t}\n\n\t$attachment_id = wp_ai_workshop_demo_set_featured_image_from_url( $post_id, $image_url );\n\tif ( is_wp_error( $attachment_id ) ) {\n\t\treturn array(\n\t\t\t'message' =&gt; 'Post created, but the featured image could not be added: ' . $attachment_id-&gt;get_error_message(),\n\t\t\t'post_id' =&gt; $post_id,\n\t\t);\n\t}\n\n\treturn array(\n\t\t'message' =&gt; 'Post created successfully with featured image.',\n\t\t'post_id' =&gt; $post_id,\n\t);\n}\n\n\/**\n * Sideload an image from a URL into the media library and set it as the\n * featured image for a post.\n *\n * @param string $post_id ID of the Post.\n * @param string $image_url URL of the external image.\n *\/\nfunction wp_ai_workshop_demo_set_featured_image_from_url( $post_id, $image_url ) {\n\t\/\/ Verify that the image_url is a valid url, just to be safe.\n\tif ( ! wp_http_validate_url( $image_url ) ) {\n\t\treturn new WP_Error( 'invalid_url', 'Image URL failed validation.' );\n\t}\n\n\t\/\/ The media_sideload_image() function is typically only used in the admin context\n\t\/\/ This ensures that it can be used outside the admin context, such as in a REST API request or MCP tool.\n\tif ( ! function_exists( 'media_sideload_image' ) ) {\n\t\trequire_once ABSPATH . 'wp-admin\/includes\/media.php';\n\t\trequire_once ABSPATH . 'wp-admin\/includes\/file.php';\n\t\trequire_once ABSPATH . 'wp-admin\/includes\/image.php';\n\t}\n\n\t$attachment_id = media_sideload_image( $image_url, $post_id, null, 'id' );\n\tif ( is_wp_error( $attachment_id ) ) {\n\t\treturn $attachment_id;\n\t}\n\n\tset_post_thumbnail( $post_id, $attachment_id );\n\n\treturn $attachment_id;\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">This is one of the greatest things about Abilities. Once your functionality is expressed as abilities, you and other developers extending your functionality can build <em>new<\/em> functionality by combining existing abilities \u2014 like LEGO bricks. It&#8217;s the same instinct that made hooks and filters so powerful, applied to a new generation of features.<\/p>\n\n\n\n<h2 id=\"step-7-update-the-admin-interface\" class=\"wp-block-heading\">Step 7: Update the Admin Interface<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">At this point, you\u2019ve completed most of the core plugin functionality. Now we just need to make it available to the site owner. As mentioned earlier, the plugin ships with a settings page, so we need to connect it to the Abilities we just registered.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The first step is to enqueue the Abilities script module in <code>includes\/admin.php<\/code>:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code lang=\"php\" class=\"language-php\">wp_enqueue_script_module( '@wordpress\/core-abilities' );<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><code>@wordpress\/core-abilities<\/code> enqueues everything you need to find and execute the Abilities you registered in PHP in JavaScript.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Next, you need to update the plugin\u2019s own script enqueuing to load it as a script module, and add the dependency on <code>@wordpress\/core-abilities<\/code>:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code lang=\"php\" class=\"language-php\">wp_enqueue_script_module(\n\t'wp-ai-workshop-demo-script',\n\tplugins_url( 'build\/index.js', __DIR__ ),\n\tarray( '@wordpress\/core-abilities' ),\n\t$asset_file['version'],\n);<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Now open the file <code>src\/components\/settings-page.jsx<\/code>, where you\u2019ll update the settings page interface.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Step one is to import <code>ready<\/code> promise from the <code>@wordpress\/abilities<\/code> package. This is a <a href=\"https:\/\/github.com\/WordPress\/gutenberg\/pull\/77254\">recent update<\/a> that ensures the Abilities REST API endpoints are loaded before any modules attempt to interact with them. Next, import the <code>getAbility<\/code> and <code>executeAbility<\/code> functions from the <code>@wordpress\/abilities<\/code> package. This allows you to get and execute your registered Abilities. At the same time, you can create a constant that holds the name of the <code>create-post-from-photo<\/code> Ability.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code lang=\"javascript\" class=\"language-javascript\">const { ready } = await import( \/* webpackIgnore: true *\/ '@wordpress\/core-abilities' );\nawait ready;\nconst { getAbility, executeAbility } = await import( \/* webpackIgnore: true *\/ '@wordpress\/abilities' );\n\nconst ABILITY = 'wp-ai-workshop-demo\/create-post-from-photo';<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Heads up:<\/strong> note the <code>\/* webpackIgnore: true *\/<\/code> comment. It tells <code>wp-scripts<\/code> not to bundle the import during its build step because WordPress provides it at runtime. There&#8217;s an <a href=\"https:\/\/github.com\/WordPress\/gutenberg\/pull\/76397\">open PR in Gutenberg<\/a> to fix this in <code>wp-scripts<\/code>, but for now, the comment is what keeps the build happy.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Then, inside the <code>generateFromInput<\/code> function, use the Abilities JavaScript API to execute the <code>create-post-from-photo<\/code> ability.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code lang=\"javascript\" class=\"language-javascript\">       const ability = getAbility( ABILITY );\n        if ( ! ability ) {\n            updateNotice( __( 'Whoops, the create-post-from-photo Ability was not found.', 'wp-ai-workshop-demo' ), 'error' );\n            return;\n        }\n\n        setIsBusy( true );\n        updateNotice( __( 'Looking at your image and writing a post\u2026 this can take a moment.', 'wp-ai-workshop-demo' ), 'info' );\n\n        try {\n            const result = await executeAbility( ABILITY, {\n                image_url: input.image_url,\n                prompt: input.prompt,\n            } );\n\n            if ( result &amp;&amp; result.post_id ) {\n                const editUrl = `post.php?post=${ result.post_id }&amp;action=edit`;\n                updateNotice(\n                    &lt;span&gt;\n                        { ( result.message || __( 'Post created.', 'wp-ai-workshop-demo' ) ) + ' ' }\n                        &lt;a href={ editUrl }&gt;{ __( 'Edit the draft post.', 'wp-ai-workshop-demo' ) }&lt;\/a&gt;\n                    &lt;\/span&gt;,\n                    'success'\n                );\n            } else {\n                updateNotice( result &amp;&amp; result.message ? result.message : __( 'Done.', 'wp-ai-workshop-demo' ), 'error' );\n            }\n        } catch ( err ) {\n            updateNotice( __( 'Error during post generation. Check the console for details.', 'wp-ai-workshop-demo' ), 'error' );\n            console.error( err );\n        } finally {\n            setIsBusy( false );\n        }<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The key detail to focus on here is that, just like in PHP, you get an Ability by its name, check that it\u2019s valid, and then execute it with the required inputs. No additional code is needed to perform the same action in both PHP and JavaScript.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">In your terminal, run <code>npm run build<\/code> inside the plugin directory to rebuild the assets.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Then head to <strong>Tools <span aria-hidden=\"true\" class=\"wp-exclude-emoji\">\u2192<\/span> WP AI Workshop Demo<\/strong>, paste in an image URL, and click <strong>Generate Post<\/strong>. The <em>same ability<\/em> you tested over <code>curl<\/code> is now driving a dashboard UI. Write once, use everywhere.<\/p>\n\n\n\n<h3 id=\"one-practical-tweak-the-timeout\" class=\"wp-block-heading\">One practical tweak: the timeout<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Photo to Post makes two AI calls in a row and then sideloads an image, so a single request can easily run longer than WordPress&#8217;s default HTTP timeout. Fortunately, the AI Client exposes a filter for increasing the timeout value:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Start by adding the callback function to <code>includes\/ai-client.php<\/code> to update the timeout<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code lang=\"php\" class=\"language-php\">function wp_ai_workshop_demo_set_request_timeout() {\n\treturn 120;\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Then, back in the main plugin file, <code>wp-ai-workshop-demo.php<\/code>, wire up the function to the filter, as you did for the Ability hooks.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code lang=\"php\" class=\"language-php\">add_filter( 'wp_ai_client_default_request_timeout', 'wp_ai_workshop_demo_set_request_timeout' );<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">This is one of those things you don&#8217;t think about until your first real request times out at the worst possible moment.<\/p>\n\n\n\n<h2 id=\"step-8-hand-it-to-an-ai-agent-mcp\" class=\"wp-block-heading\">Step 8: Hand it to an AI agent (MCP)<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Here&#8217;s where it gets a little bit magical. We&#8217;ve got abilities that work over REST and in the dashboard. With one small change, they can also be driven by an AI agent in a desktop app.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Inside the <code>wp-ai-workshop-demo\/create-post-from-photo<\/code> Ability registration, update the meta array to add support for MCP.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code lang=\"php\" class=\"language-php\">'meta' =&gt; array(\n\t'show_in_rest' =&gt; true,\n\t'mcp'          =&gt; array(\n\t\t'public' =&gt; true, \/\/ Expose this ability via MCP.\n\t),\n),<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Now download, install, and activate the <a href=\"https:\/\/github.com\/WordPress\/mcp-adapter\/releases\">MCP Adapter<\/a> plugin on your site. This will create a default MCP server on the WordPress site.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Using the same Application Password you created earlier, you can configure your AI agent with the default WordPress MCP server details. Below is an example of how you would do this inside the Claude <code>~\/.config\/Claude\/claude_desktop_config.json<\/code> file :<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code lang=\"json\" class=\"language-json\">{\n  \"mcpServers\": {\n    \"wordpress-ai-demo\": {\n      \"command\": \"npx\",\n      \"args\": [ \"-y\", \"@automattic\/mcp-wordpress-remote@latest\" ],\n      \"env\": {\n        \"WP_API_URL\": \"https:\/\/yoursite.local\/wp-json\/mcp\/mcp-adapter-default-server\",\n        \"WP_API_USERNAME\": \"your-username\",\n        \"WP_API_PASSWORD\": \"your-application-password\"\n      }\n    }\n  }\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">(If you&#8217;re a VS Code user, the MCP block is named <code>servers<\/code> rather than <code>mcpServers<\/code>.)<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Now ask your MCP client to <em>&#8220;create a post from this photo&#8221;<\/em> with an image URL \u2014 and watch it discover and call your <code>create-post-from-photo<\/code> ability all on its own. The same ability. Three different front doors: REST, the dashboard, and an AI agent. We never wrote separate code for any of them.<\/p>\n\n\n\n<h2 id=\"wrap-up\" class=\"wp-block-heading\">Wrap up<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">One of the great things about building on top of the WordPress Core AI projects is that you don\u2019t need to reinvent anything to add AI-powered features to your plugins.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">You register <strong>abilities<\/strong> that describe what your plugin can do. You let the <strong>AI Client<\/strong> handle the messy provider-specific details. You <strong>compose<\/strong> small abilities into bigger ones. And then REST, the block editor, and MCP agents all come along for the ride, for free.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">And if you build something with it \u2014 or get gloriously stuck \u2014 I&#8217;d love to hear about it in the comments. That&#8217;s the best part of putting these things out into the open. Now go and build your first AI-powered plugin. \ud83d\ude80<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><em>Props <a href='https:\/\/profiles.wordpress.org\/juanmaguitar\/' class='mention'><span class='mentions-prefix'>@<\/span>juanmaguitar<\/a> and <a href='https:\/\/profiles.wordpress.org\/greenshady\/' class='mention'><span class='mentions-prefix'>@<\/span>greenshady<\/a> for editorial review.<\/em><\/p>\n","protected":false},"excerpt":{"rendered":"<p>If you&#8217;ve been watching the Core AI projects land and wondering how the pieces fit together in a real plugin, then this post is for you. We&#8217;re going to build one together, step by step, focusing on how to implement each of the three AI building blocks.<\/p>\n","protected":false},"author":7511363,"featured_media":6333,"comment_status":"open","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"_jetpack_newsletter_access":"","_jetpack_dont_email_post_to_subs":false,"_jetpack_newsletter_tier_id":0,"_jetpack_memberships_contains_paywalled_content":false,"_jetpack_memberships_contains_paid_content":false,"footnotes":"","jetpack_publicize_message":"","jetpack_publicize_feature_enabled":true,"jetpack_social_post_already_shared":true,"jetpack_social_options":{"image_generator_settings":{"template":"edge","default_image_id":0,"font":"","enabled":false},"version":2},"_wpas_customize_per_network":false,"jetpack_post_was_ever_published":false},"categories":[4,206],"tags":[217,10,93],"class_list":["post-6323","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-advanced","category-ai","tag-ai","tag-extenders","tag-plugin","mentions-greenshady","mentions-juanmaguitar"],"revision_note":"","jetpack_publicize_connections":[],"jetpack_featured_media_url":"https:\/\/developer.wordpress.org\/news\/files\/2026\/07\/build-your-first-ai-plugin.png","jetpack_sharing_enabled":true,"_links":{"self":[{"href":"https:\/\/developer.wordpress.org\/news\/wp-json\/wp\/v2\/posts\/6323","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/developer.wordpress.org\/news\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/developer.wordpress.org\/news\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/developer.wordpress.org\/news\/wp-json\/wp\/v2\/users\/7511363"}],"replies":[{"embeddable":true,"href":"https:\/\/developer.wordpress.org\/news\/wp-json\/wp\/v2\/comments?post=6323"}],"version-history":[{"count":5,"href":"https:\/\/developer.wordpress.org\/news\/wp-json\/wp\/v2\/posts\/6323\/revisions"}],"predecessor-version":[{"id":6336,"href":"https:\/\/developer.wordpress.org\/news\/wp-json\/wp\/v2\/posts\/6323\/revisions\/6336"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/developer.wordpress.org\/news\/wp-json\/wp\/v2\/media\/6333"}],"wp:attachment":[{"href":"https:\/\/developer.wordpress.org\/news\/wp-json\/wp\/v2\/media?parent=6323"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/developer.wordpress.org\/news\/wp-json\/wp\/v2\/categories?post=6323"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/developer.wordpress.org\/news\/wp-json\/wp\/v2\/tags?post=6323"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}