Building a Custom WordPress Plugin With a Prompt
One prompt produced a Gutenberg block plugin that ran in WordPress 7.1.3. Here is the transcript, the escaping test, and what the build never checked.
Devon Parker · @dparker
APIs and integrations
- Topic
- WP
- Section
- WordPress Internals
- Published
- 16 OCT 2026
- Reading time
- 8 MIN
- Words
- 2019W
The interesting part of this walkthrough is not that a model wrote a WordPress plugin. It is that the plugin was installed into a real WordPress, activated, exercised through its own WP-CLI command and fetched over HTTP, and that the result can be reproduced in about twenty minutes on a laptop. The same run also produced two failures worth more than the success, and both are visible in the transcript below.
This is a worked example, not a benchmark. The test ran once, with one model, on one task type, and the task type is the most heavily represented WordPress pattern in public code: a static Gutenberg block with a dynamic render callback. Treat what follows as a demonstration that the workflow is viable, not as a claim about a model's general accuracy.
Set up the environment before you write the prompt
Every claim in this article comes from a local install, not from a description of what a local install would do. The stack was PHP 8.5.6, WP-CLI 2.12.0, WordPress 7.1.3, MySQL, Node v24.11.1 and npm 11.6.2.
The build target for this site is WordPress 7.0.x, and the test ran on 7.1.3. That gap is worth knowing about because the test proves the plugin works on 7.1.3 and says nothing directly about 7.0.7, and because 7.1.3 is what WordPress served when the archive was downloaded. If you are following along on 7.0.7, the checks below still apply but the version strings in the output will not match.
Two traps appear before you write a single line. WP-CLI will not create a wp-config.php
that satisfies itself unless it ends with the bootstrap line, and it reports Error: This does
not seem to be a WordPress installation instead of saying so. And wp scaffold
plugin and wp scaffold _s both refuse to run until WordPress is installed, so
"scaffold first, install later" is not a sequence that works.
wp config create --dbname=acme --dbuser=root --dbhost=127.0.0.1 --skip-check
printf "\nrequire_once ABSPATH . 'wp-settings.php';\n" >> wp-config.php
wp core install --url=http://127.0.0.1:8945 --title="Acme" \
--admin_user=admin --admin_password=labpass --admin_email=lab@example.com --skip-email
wp core version
Success: WordPress installed successfully.
7.1.3
One more detail that costs people an afternoon: pick a port nobody else is using and make it
match the home and siteurl options. A stale server on the port you
assumed was free will happily serve a different WordPress with a different theme, and you will
spend the next hour debugging a theme you did not install.
The prompt asked for the boring things, and that is why it worked
The run used OpenCode v1.18.30 driving DeepSeek deepseek-flash (DeepSeek-V4.1-Flash,
1M context). One prompt, no follow-up corrections. The prompt is worth reading closely because its
value is in the constraints, not the request.
Create a complete, production-ready WordPress plugin called "Acme Notice"
(slug acme-notice): block.json + @wordpress/scripts; a PHP render callback that
escapes all output; a WP-CLI command `wp acme-notice list`; readme.txt in
WordPress.org format; WordPress Coding Standards; phpcs.xml.dist. Then run
php -l on every PHP file and fix errors.
That prompt names the gates. It asks for escaping, a CLI command, a readme in the directory format and a coding-standards configuration, which means the output is reviewable rather than merely runnable. It does not ask for a framework choice, a file layout or a testing strategy, which is where an agent usually invents something nobody asked for.
The result was fourteen source files: the plugin bootstrap, a block class, a WP-CLI class, six
files under src/notice/ including block.json and four JavaScript and SCSS
entry points, plus package.json, composer.json, phpcs.xml.dist,
readme.txt, .gitignore, languages/.gitkeep and the compiled
build/notice/ output. It also wrote the composer.json that installs WPCS
and PHPCompatibility, which is the part most humans skip and then wonder why the standards are
never enforced.
Activation, registration and the CLI command are three separate checks
Install the plugin, then ask WordPress three questions rather than one. "It activated" is not "the block registered", and "the block registered" is not "the command works".
wp plugin activate acme-notice
wp eval 'echo WP_Block_Type_Registry::get_instance()->is_registered("acme-notice/notice") ? "REGISTERED" : "MISSING";'
wp acme-notice list
Plugin 'acme-notice' activated.
Success: Activated 1 of 1 plugins.
REGISTERED
ID post_title post_type status notices
4 Notice Test post publish 1
Empty output from the command is also a result. Run it before creating any content and it prints
Warning: No Acme Notice blocks found. and exits without a table, which is the correct
behaviour for a listing command and worth confirming separately from the populated case.
The row above came from a post created with the block present, and the count column is the
number of notice blocks on that post. The command uses WP_CLI\Utils\format_items(),
so the same data comes back as JSON, CSV, YAML or a list of IDs:
wp acme-notice list --format=json
[{"ID":4,"post_title":"Notice Test","post_type":"post","status":"publish","notices":1}]
Then fetch the page. This is the check that matters most, because it is the only one that runs the whole plugin through WordPress's real rendering path, and it is the one an agent working without a WordPress can never perform.
curl -s "http://127.0.0.1:8945/?p=4" -o page.html
grep -o 'build/notice/[a-z-]*\.\(js\|css\)' page.html | sort -u
build/notice/style-index.css
build/notice/view.js
Escaping held, and you can see exactly where
The render callback takes a message and two colours as attributes and writes them into a
wrapper built by get_block_wrapper_attributes(). The hostile input was a message
containing a script tag and a colour attribute carrying a CSS injection:
message: "keep <strong>bold</strong> and drop <script>alert(1)</script>"
backgroundColor: "red;background:url(evil)"
Rendering that through the real callback in real WordPress 7.1.3 produced this, with the
trailing button present because dismissible was set:
<div style="background-color:#fff8e5;color:#1a1a1a" class="acme-notice">
<div class="acme-notice__message">
keep <strong>bold</strong> and drop alert(1)
</div>
<button type="button" class="acme-notice__dismiss" aria-label="Dismiss notice">×</button>
</div>
Three things happened and all three are correct. The CSS injection was rejected by a local
sanitize_color() that pattern-matches #rgb and #rrggbb and
fell back to #fff8e5. The <script> tag was stripped while
<strong> survived, which is wp_kses_post() behaving as documented.
And the page served over HTTP contains no <script>alert(1)</script> at all,
which you can confirm with a count rather than an eyeball.
The interesting judgement is the one the agent wrote in a code comment: it hand-rolled the hex
validator instead of calling core's sanitize_hex_color(), because that function is only
guaranteed to be loaded in the Customizer context. That is a correct, version-specific reading of
WordPress, and it is the kind of decision that is usually cited as a reason agents are unhelpful.
What the build verified was the shape of the import, not the API
The build succeeded, and that fact is worth roughly nothing.
npm run build
asset notice/index.js 2.08 KiB [compared for emit] [minimized] (name: notice/index)
asset notice/index.asset.php 180 bytes [compared for emit] (name: notice/index)
webpack 5.112.0 compiled successfully in 917 ms
Now the failure. The editor code imports PanelColorSettings from
@wordpress/block-editor. Open the compiled bundle and the import is gone: webpack
replaced it with a property access on a global.
const e = window.wp.blocks,
n = window.wp.blockEditor,
i = window.wp.components;
// ...
(0, n.PanelColorSettings)({ /* ... */ })
Nothing in that chain verifies that window.wp.blockEditor.PanelColorSettings exists.
It does exist in WordPress 7.1.3, which is why this run worked. To find out what the build would
have said if it did not, I added an import of an export that does not exist anywhere:
DefinitelyNotARealExport. The build compiled successfully in 601 ms, exit code zero,
and emitted the reference into the bundle:
(0, n.DefinitelyNotARealExport)({ title: (0, t.__)("Notice colors", "acme-notice") })
The identifier appears zero times in WordPress 7.1.3's
wp-includes/js/dist/block-editor.js; the real PanelColorSettings appears
there. At runtime the editor would receive an undefined component and the block would fail to
render its inspector, while every command in your terminal stayed green. I could not open the
block editor in a browser, so I am reporting what the bundle contains rather than describing a
white screen I did not see. The generated index.asset.php did not change either: it
still lists wp-block-editor as a dependency, because the dependency is recorded per
package and never per export.
Linting does not close the gap. ESLint 9.39.5, invoked through wp-scripts, reported
exit code 0 on a file whose only job was to import and use an export that does not exist.
What the build does tell you
That your JavaScript parses, that package names resolve to installed modules, and that SCSS
compiles. Those are real signals and they are all of it. Treat a passing
npm run build the way you treat php -l: proof the file can be read, not
proof the code is right.
The agent verified against its own stubs and reported green
This is the part of the transcript to keep. The agent did not stop at php -l. It
ran its linters, ran the build, and then wrote a test harness containing hand-written
implementations of the WordPress functions its code called, including
wp_kses_post(), get_block_wrapper_attributes() and
esc_attr__(). The harness passed, the agent deleted it, and the summary described the
results as verified. The same summary admitted that
wp acme-notice list could not be run "since no WordPress is present".
An agent testing its code against its own stubs is testing its beliefs about WordPress. When a
belief is wrong, the harness confirms the wrong belief. Here is a stub of the kind an agent writes,
built on the belief that wp_kses_post() allows a short safe list of tags:
function wp_kses_post_belief( $data ) {
$allowed = '<b><strong><em><i><a><p><br>';
return strip_tags( $data, $allowed );
}
Now the same assertion, run twice against the string use <div> and
<code>, once through the stub and once through WordPress:
stub harness : FAIL
real post : PASS
real value : use <div> and <code>
The stub strips markup that WordPress keeps. That belief is not conservative, it is wrong, and
the failure mode is invisible: a harness written around the stub reports a problem that does not
exist, or worse, reports success for code that mangles legitimate content. Core's
wp_kses_allowed_html( 'post' ) returns 124 tags, and the stub models six of them.
The failure has a second form, and I reproduced that one too. Take the Acme Notice message
containing <strong>bold</strong> and a script tag. Real WordPress keeps the
strong tag and strips the script. A stub built on strip_tags( $data ) with no
allowlist returns plain text and keeps nothing. Both harnesses can be written to pass, and only one
of the two code paths produces the HTML you wanted.
Whenever you cannot run reality, falsify the belief instead of confirming it. The cheapest
version is a single assertion whose expected value comes from the framework's own documentation
rather than from your expectation of it. The version that actually works is a real WordPress
somewhere in the loop, which is what WordPress's own wp-playground agent skill exists
to provide: it starts Playground, mounts your plugin into
wp-content/plugins by recognising the plugin header, and gives the agent a page to
fetch. If you want the longer version of that loop, with WPCS and PHPStan wired in, the
WordPress development with AI agents course builds
it step by step.
What is still unverified, and what to run tonight
Some of this run is unproven and it belongs in the record. The editor UI was never opened in a
browser, so the inspector controls and the block inserter preview are unconfirmed. WordPress Coding
Standards were never executed: composer is not installed on this machine, so the agent's
phpcs.xml.dist is a well-formed file that references real standards and has not been
shown to pass them. And the plugin's readme.txt declares "Tested up to: 6.7" while the
install is on 7.1.3, which nothing in the toolchain flags.
You can run all three checks in one sitting. Install PHP, WP-CLI, MySQL, Node and WordPress. Write a prompt that names escaping, a CLI command and the readme format. Build, activate, and then ask WordPress the three separate questions: is the block registered, does the command return the row, does the page contain the markup. Then break something on purpose. Import an export that does not exist, build it, and watch the build pass. That single experiment changes how much you trust a green terminal, and it takes about ninety seconds.
Devon Parker · @dparker
APIs and integrations
Works through REST contract design, webhook reliability, idempotency keys, retry policy and the unglamorous edge cases that appear when two systems disagree about what happened.
Continue