<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Illia Kopturov</title>
    <description>The latest articles on DEV Community by Illia Kopturov (@ilyakop).</description>
    <link>https://dev.to/ilyakop</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4053911%2F49bc40f3-36da-4532-b3bc-1426abaa20c7.jpg</url>
      <title>DEV Community: Illia Kopturov</title>
      <link>https://dev.to/ilyakop</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ilyakop"/>
    <language>en</language>
    <item>
      <title>WordPress 7.0 Shipped a Native AI Client. Nobody's Building On It. I Did.</title>
      <dc:creator>Illia Kopturov</dc:creator>
      <pubDate>Thu, 30 Jul 2026 01:13:59 +0000</pubDate>
      <link>https://dev.to/ilyakop/wordpress-70-shipped-a-native-ai-client-nobodys-building-on-it-i-did-1j39</link>
      <guid>https://dev.to/ilyakop/wordpress-70-shipped-a-native-ai-client-nobodys-building-on-it-i-did-1j39</guid>
      <description>&lt;p&gt;WordPress 7.0 quietly shipped something big: a native AI Client&lt;br&gt;
(&lt;code&gt;wp_ai_client_prompt()&lt;/code&gt;), baked straight into WordPress core.&lt;/p&gt;

&lt;p&gt;One function call. Any registered provider — Anthropic, OpenAI, Google. No API&lt;br&gt;
key wrangling in your plugin's settings page. No vendor lock-in for your users.&lt;/p&gt;

&lt;p&gt;I went looking for who'd built a real product on top of it.&lt;/p&gt;

&lt;p&gt;Core team demos. A couple of proof-of-concept gists. That's about it.&lt;/p&gt;

&lt;p&gt;So I built one. This post is about the architecture, not a sales pitch — I'll&lt;br&gt;
drop the link at the end for anyone curious, but the point here is the pattern,&lt;br&gt;
because if you build WordPress plugins, you're going to run into this problem&lt;br&gt;
soon whether you touch AI or not.&lt;/p&gt;
&lt;h2&gt;
  
  
  The problem every AI-powered plugin has been quietly ignoring
&lt;/h2&gt;

&lt;p&gt;If you've shipped or used an "AI content" plugin in the last two years, it&lt;br&gt;
almost certainly works like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Plugin ships with a settings field for "OpenAI API Key"&lt;/li&gt;
&lt;li&gt;You paste in a key, it gets stored in &lt;code&gt;wp_options&lt;/code&gt; (sometimes encrypted,
often not)&lt;/li&gt;
&lt;li&gt;You're locked to whatever provider that plugin's developer chose to
integrate&lt;/li&gt;
&lt;li&gt;If you use three AI-powered plugins, you're pasting the same key (or three
different keys) into three different settings screens, trusting three
different developers' key-handling code&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's not a WordPress problem, it's every plugin author solving the same&lt;br&gt;
integration problem badly, in isolation, forever.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;wp_ai_client_prompt()&lt;/code&gt; fixes this at the platform level. The user connects a&lt;br&gt;
provider &lt;em&gt;once&lt;/em&gt;, in core, under &lt;strong&gt;Settings → Connectors&lt;/strong&gt;. Every plugin that&lt;br&gt;
wants AI capability just calls the client — it doesn't know or care which&lt;br&gt;
provider is behind it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;wp_ai_client_prompt&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;with_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'Write a 150-word product description for a ceramic espresso cup.'&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;using_max_tokens&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;generate_text&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nf"&gt;is_wp_error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$result&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;echo&lt;/span&gt; &lt;span class="nf"&gt;esc_html&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$result&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's a fluent builder: &lt;code&gt;with_text()&lt;/code&gt;, &lt;code&gt;using_system_instruction()&lt;/code&gt;, &lt;code&gt;using_max_tokens()&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;using_model_preference()&lt;/code&gt; — chain what you need, call &lt;code&gt;generate_text()&lt;/code&gt;, get a string back&lt;br&gt;
or a &lt;code&gt;WP_Error&lt;/code&gt;. No provider-specific request shape to build, no provider-specific error&lt;br&gt;
format to catch.&lt;/p&gt;

&lt;p&gt;No key in my plugin's database. No provider lock-in for the user. If they&lt;br&gt;
swap their connector from Claude to GPT-4o next month, every plugin using the&lt;br&gt;
AI Client just... uses GPT-4o next month. Zero code change on my end.&lt;/p&gt;

&lt;p&gt;This is the same shift &lt;code&gt;wp_mail()&lt;/code&gt; was for email transport twenty years ago —&lt;br&gt;
boring, structural, and exactly the kind of thing that matters more than any&lt;br&gt;
single feature built on top of it.&lt;/p&gt;
&lt;h2&gt;
  
  
  What I built on it: Wordvane
&lt;/h2&gt;

&lt;p&gt;Wordvane is an SEO content generator that lives inside wp-admin. You fill out&lt;br&gt;
a business profile once — what you do, who you sell to, brand voice, up to&lt;br&gt;
three products you want linked — and it generates full, structured articles:&lt;br&gt;
keyword research, secondary keywords, meta title/description, FAQ schema, the&lt;br&gt;
whole publish-ready package, converted straight into native Gutenberg blocks&lt;br&gt;
(not a wall of HTML you have to paste and reformat).&lt;/p&gt;

&lt;p&gt;The reason this post exists at all is that none of that required me to touch&lt;br&gt;
a single provider SDK, handle a single API key, or build my own "choose your&lt;br&gt;
AI model" settings screen. The AI Client did that. I got to spend my time on&lt;br&gt;
the actual product: article structure, SEO scoring, internal linking, not&lt;br&gt;
plumbing.&lt;/p&gt;

&lt;p&gt;Here are the parts I think are actually worth showing, not just describing.&lt;/p&gt;
&lt;h3&gt;
  
  
  1. Detecting whether a provider is even connected
&lt;/h3&gt;

&lt;p&gt;Before Wordvane lets anyone generate anything, it checks the AI Client's own&lt;br&gt;
provider registry — not a Wordvane setting, the &lt;em&gt;core&lt;/em&gt; registry:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;wordvane_has_configured_ai_provider&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nb"&gt;class_exists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'WordPress\AiClient\AiClient'&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nv"&gt;$registry&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;\WordPress\AiClient\AiClient&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;defaultRegistry&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$registry&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getRegisteredProviderIds&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$id&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$registry&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;isProviderConfigured&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$id&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If nothing's connected, Wordvane doesn't error out mid-generation — it shows an&lt;br&gt;
admin notice pointing the user at &lt;strong&gt;Settings → Connectors&lt;/strong&gt; before they ever&lt;br&gt;
hit "Generate." Small thing, but it's the difference between "plugin is&lt;br&gt;
broken" and "you haven't finished setup yet," and it costs four lines because&lt;br&gt;
the registry is public API, not something I had to build myself.&lt;/p&gt;
&lt;h3&gt;
  
  
  2. The actual generation call, end to end
&lt;/h3&gt;

&lt;p&gt;This is the real shape, not a simplified stand-in — system instruction, user&lt;br&gt;
message, token budget, optional model preference, all chained onto one client&lt;br&gt;
call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$prompt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;wp_ai_client_prompt&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;using_system_instruction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$system_prompt&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;with_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$user_message&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;using_max_tokens&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$max_tokens&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="k"&gt;empty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$model_preference&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$prompt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$prompt&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;using_model_preference&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$model_preference&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nv"&gt;$result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$prompt&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;generate_text&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nf"&gt;is_wp_error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$result&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;wp_send_json_error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="s1"&gt;'message'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$result&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get_error_message&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;using_model_preference()&lt;/code&gt; is the interesting one — it's a plain string&lt;br&gt;
(e.g. a specific Claude or GPT model name), and the Client resolves it against&lt;br&gt;
whatever's actually connected. If the preferred model isn't available, it&lt;br&gt;
falls back gracefully instead of hard-failing. I never write provider-specific&lt;br&gt;
branching anywhere in this codebase.&lt;/p&gt;
&lt;h3&gt;
  
  
  3. Getting structured data back without relying on provider-specific JSON modes
&lt;/h3&gt;

&lt;p&gt;Not every provider behind the AI Client supports the same structured-output&lt;br&gt;
or tool-calling features yet, so instead of depending on that, Wordvane makes&lt;br&gt;
a second, smaller generation call against the &lt;em&gt;already-generated article&lt;/em&gt; and&lt;br&gt;
just asks for JSON in plain text:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$prompt_text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Output ONLY a valid JSON object — no explanation, no code "&lt;/span&gt;
    &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s2"&gt;"fences, no markdown.&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s2"&gt;Required fields:&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s2"&gt;"- meta_title, meta_description, slug, tags, faq_schema&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s2"&gt;"Article:&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$plain_text_article&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nv"&gt;$response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;wp_ai_client_prompt&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;with_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$prompt_text&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;using_max_tokens&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="mi"&gt;1200&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;generate_text&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="nv"&gt;$json_str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$response&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nb"&gt;strncmp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$json_str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'```

'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$json_str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;preg_replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'/^

```[a-z]*\n?/'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$json_str&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$json_str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;rtrim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$json_str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;" &lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;`"&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nv"&gt;$meta&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;json_decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$json_str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Unglamorous, but it works identically across every provider today, and it&lt;br&gt;
means I'm not blocked waiting on structured-output support to reach parity&lt;br&gt;
across Anthropic, OpenAI, and Google's implementations.&lt;/p&gt;
&lt;h3&gt;
  
  
  4. Converting AI output into real Gutenberg blocks, not an HTML blob
&lt;/h3&gt;

&lt;p&gt;The generated article comes back as flat HTML. Pasting that into a post&lt;br&gt;
leaves you with a classic-editor-style content blob sitting inside the block&lt;br&gt;
editor — technically fine, practically a mess to edit afterward. Wordvane&lt;br&gt;
walks the DOM and emits real block comments:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;node_to_block&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="kt"&gt;DOMDocument&lt;/span&gt; &lt;span class="nv"&gt;$dom&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;DOMNode&lt;/span&gt; &lt;span class="nv"&gt;$node&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$tag&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;strtolower&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$node&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;nodeName&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$inner&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;inner_html&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$dom&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$node&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$tag&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s1"&gt;'h2'&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;"&amp;lt;!-- wp:heading --&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;h2 class=&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;wp-block-heading&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$inner&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/h2&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;!-- /wp:heading --&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s1"&gt;'p'&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;"&amp;lt;!-- wp:paragraph --&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;p&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$inner&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;!-- /wp:paragraph --&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="c1"&gt;// ...ul/ol handled the same way, wrapped as wp:list / wp:list-item&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every paragraph, heading, and list item lands as an editable native block —&lt;br&gt;
you can rearrange or delete a paragraph the same way you would on any post&lt;br&gt;
you wrote by hand.&lt;/p&gt;
&lt;h3&gt;
  
  
  5. Regression-testing prompt behavior with deliberately "loud" test data
&lt;/h3&gt;

&lt;p&gt;This is the one I'd actually push other WP+AI plugin developers to steal.&lt;br&gt;
Early on I hit a bug where brand names and product details leaked into&lt;br&gt;
articles that were supposed to be neutral, regardless of what the user had&lt;br&gt;
configured. The fix wasn't a prompt tweak — it was realizing that a business&lt;br&gt;
name embedded in a free-text field (like &lt;code&gt;what_they_sell&lt;/code&gt; containing "Acme Co&lt;br&gt;
premium widgets") skips right past a naive "just omit the business_name&lt;br&gt;
field" filter.&lt;/p&gt;

&lt;p&gt;The regression tests use a deliberately unmistakable fake brand so any leak&lt;br&gt;
is impossible to miss in a diff or a failing assertion:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;dna&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'business_name'&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'GlacierBrew Co'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'what_they_sell'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'GlacierBrew Co cold-process ales, brewed in small batches'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c1"&gt;// brand name embedded in a *different* field on purpose&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;test_none_system_prompt_excludes_what_they_sell&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$prompt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Wordvane_Prompt_Builder&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;build_system_prompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;dna&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'craft beer fermentation'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'how-to'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'none'&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;assertStringNotContainsStringIgnoringCase&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s1"&gt;'GlacierBrew'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nv"&gt;$prompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'Brand name embedded in what_they_sell must not leak into none-mode prompt'&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you're building anything that assembles prompts from user-supplied&lt;br&gt;
profile data, this pattern is worth copying directly: pick a fake brand name&lt;br&gt;
nobody would type by accident, plant it in every field that could plausibly&lt;br&gt;
carry it, and assert it's gone from whichever mode is supposed to suppress it.&lt;br&gt;
It turns "I think I fixed the leak" into something CI actually enforces.&lt;/p&gt;

&lt;h2&gt;
  
  
  Screenshots
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fg8keuq9ayand8dln2spq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fg8keuq9ayand8dln2spq.png" alt="Business DNA profile" width="800" height="457"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Business DNA profile — this is the only setup step; every article pulls from it.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fefrplqsd90c7egtw7z1m.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fefrplqsd90c7egtw7z1m.png" alt="Generate screen" width="800" height="442"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Generate screen — provider selection reads directly from whatever's connected under Settings → Connectors.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F69iogtopc7m8onyvaseu.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F69iogtopc7m8onyvaseu.png" alt="Gutenberg blocks" width="800" height="486"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Output arrives as real Gutenberg blocks, not raw HTML to clean up.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0vr23gyzr8dyt06eyl8w.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0vr23gyzr8dyt06eyl8w.png" alt="Live SEO Score panel" width="800" height="601"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Six checks before publish — keyword placement, meta length, word count, FAQ presence.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it / build on it
&lt;/h2&gt;

&lt;p&gt;If you're a WordPress plugin developer sitting on an idea that needs AI and&lt;br&gt;
you haven't looked at &lt;code&gt;wp_ai_client_prompt()&lt;/code&gt; yet — that's the actual thing&lt;br&gt;
worth taking away from this post. It's a small API surface and it removes an&lt;br&gt;
entire category of problem (key storage, provider lock-in, settings-screen&lt;br&gt;
bloat) that most of us have been solving badly for years.&lt;/p&gt;

&lt;p&gt;Wordvane itself is free to install, unlimited generation on the free tier —&lt;br&gt;
&lt;a href="https://wordvane.com" rel="noopener noreferrer"&gt;wordvane.com&lt;/a&gt; if you want to see what I built with it.&lt;/p&gt;

&lt;p&gt;Happy to go deeper in the comments — AI Client internals, the block&lt;br&gt;
conversion, the prompt-leak regression tests, whatever's useful. Genuinely&lt;br&gt;
curious what other WP devs are running into building on this.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>wordpress</category>
      <category>seo</category>
    </item>
  </channel>
</rss>
