Skip to content
System nominalUTC --:--:--Articles 085

Building a Custom WordPress Theme With a Prompt

Both theme paths are open, and WP-CLI picks the classic one for you. Here is what each produces, what I verified in WordPress 7.1.3, and what a prompt cannot see.

Devon Parker · @dparker

APIs and integrations

Topic
WP
Section
WordPress Internals
Published
16 OCT 2026
Reading time
8 MIN
Words
2142W
Technical cover graphic for Building a Custom WordPress Theme With a Prompt
Generated schematic — WP series, REV 2.6.

The plugin case is the easy one, and it is easy for a reason people rarely name: a plugin is a single artefact with a single entry point and a command-line interface you can poke. A theme is a set of files WordPress discovers, interprets and merges with its own defaults, and the interesting failures happen during that merge rather than in your source.

So this article is honest about the harder half. Both theme paths are open to a reader with an agent, the tooling pushes you down the older one without saying so, and the block path has a class of silent failure that no local build will surface. Everything below was run against WordPress 7.1.3 with WP-CLI 2.12.0 on PHP 8.5.6. The site's stated target is WordPress 7.0.x, and the verification here is on 7.1.3.

WP-CLI picks the theme architecture for you

Start with the command you would naturally reach for, because its output is the finding. There is no wp scaffold theme subcommand; the Underscores generator is wp scaffold _s, with wp scaffold underscores as the same thing spelled out. Run it and you get a classic theme.

wp scaffold _s acme-classic --theme_name="Acme Classic" --author="Acme"
find wp-content/themes/acme-classic -type f | wc -l
ls wp-content/themes/acme-classic/theme.json
head -8 wp-content/themes/acme-classic/style.css
Success: Created theme 'Acme Classic'.
36
ls: wp-content/themes/acme-classic/theme.json: No such file or directory
/*!
Theme Name: Acme Classic
Theme URI: http://underscores.me/
Author: Acme
Version: 1.0.0
Tested up to: 5.4
Requires PHP: 5.6
Text Domain: acme-classic

Thirty-six files, twenty of them PHP templates: header.php, footer.php, single.php, page.php, archive.php, search.php, sidebar.php, 404.php and the template-parts/ directory. No theme.json anywhere. The header still declares support up to WordPress 5.4 and a minimum of PHP 5.6, which is old enough that the file is older than the block editor's style system.

None of that makes the output broken. I activated it on WordPress 7.1.3 and fetched the home page: HTTP 200, the theme's own id="masthead" header and id="primary" content wrapper both present, and wp_get_theme()->is_block_theme() returning false, which is correct for what it is. Underscores is a maintained classic theme and it works. The problem is the mismatch between what a reader asked for and what the tool assumed.

Scaffold after the install, not before

wp scaffold _s and wp scaffold plugin both refuse to run until WordPress is installed, and the error they print is Error: This does not seem to be a WordPress installation rather than an explanation. Install WordPress and a database first, then scaffold.

A classic theme is still the right answer for some sites

Classic is not a downgrade by default. It is the correct choice when a site depends on PHP templates that a plugin or an agency has customised, when the theme wraps a page builder that generates its own markup, when the site is a WooCommerce store with template overrides in a child theme, or when the person maintaining it edits PHP and not block markup. The block editor will still edit the content of a classic theme; you simply do not get theme.json, global styles or the Site Editor.

The prompt for this path should ask for what classic themes need and nothing more. Name the templates, name the hooks, name the escaping function for each output context, and say which WordPress version the code targets so the agent does not produce something aimed at 5.4.

Create a classic WordPress theme called "Acme Classic" (slug acme-classic)
targeting WordPress 7.0 and PHP 8.3. Include style.css, functions.php,
header.php, footer.php, index.php, single.php, page.php, archive.php, search.php,
404.php and template-parts/content.php. Enqueue styles and scripts through
wp_enqueue_scripts. Escape every output with the context-appropriate function
(esc_html, esc_attr, esc_url, wp_kses_post). Add theme support for title-tag,
post-thumbnails and html5. Do not add a theme.json.

The failure to watch for here is the same one that bites every classic theme: an escaping function chosen by habit rather than context, and a template that calls a pluggable function directly instead of a hook. Neither shows up in a syntax check.

A block theme is three files, and the restraint is the hard part

The block path looks like less work and is genuinely less code, but only if you resist filling it with PHP. WordPress 7.1.3 accepts a theme built from a style.css with a valid header, a theme.json and a templates/index.html that contains block markup. There is no functions.php in the minimum.

<!-- wp:group {"tagName":"main","layout":{"type":"constrained"}} -->
<main class="wp-block-group">
<!-- wp:post-title {"level":1} /-->
<!-- wp:post-content /-->
</main>
<!-- /wp:group -->

Point $schema at the version you are targeting. I checked the URL rather than assuming it resolves: https://schemas.wp.org/wp/7.1/theme.json returns a redirect to the Gutenberg repository's schemas/json/theme.json on the wp/7.1 branch, and that schema pins version to the integer 3. That is a runtime fetch by your editor or CI, not by WordPress, so a wrong schema URL degrades your tooling rather than your site.

Here is the theme.json that produced the output below, with the vivid-red entry left in on purpose:

{
  "$schema": "https://schemas.wp.org/wp/7.1/theme.json",
  "version": 3,
  "settings": {
    "appearanceTools": true,
    "layout": { "contentSize": "640px", "wideSize": "1100px" },
    "color": {
      "palette": [
        { "slug": "base", "color": "#ffffff", "name": "Base" },
        { "slug": "contrast", "color": "#111111", "name": "Contrast" },
        { "slug": "accent", "color": "#1f6feb", "name": "Accent" },
        { "slug": "vivid-red", "color": "#00ff00", "name": "Deliberately Not Red" }
      ]
    },
    "typography": {
      "fontSizes": [
        { "slug": "caption", "size": "0.875rem", "name": "Caption" },
        { "slug": "body", "size": "1.125rem", "name": "Body" },
        { "slug": "display", "size": "1.75rem", "name": "Display" }
      ]
    }
  },
  "styles": {
    "color": { "background": "var:preset|color|base", "text": "var:preset|color|contrast" },
    "typography": { "fontSize": "var:preset|font-size|body" }
  }
}

Activate it, confirm WordPress agrees about what it is, then read the CSS that reaches the page. Asking the front end rather than the file is the whole point of this step.

wp theme activate acme-block
wp eval 'echo wp_get_theme()->is_block_theme() ? "block" : "classic", "\n";'
curl -s http://127.0.0.1:8945/ | grep -o -- "--wp--preset--color--[a-z-]*:[^;}]*" | sort -u
block
--wp--preset--color--accent: #1f6feb
--wp--preset--color--base: #ffffff
--wp--preset--color--contrast: #111111
--wp--preset--color--vivid-red: #cf2e2e

Three of those four are mine. The fourth is not, and the mechanism is worth understanding because it is silent by design rather than by accident. My file asks for vivid-red to be #00ff00. WordPress printed #cf2e2e, core's own value for that slug. Ask the resolver what happened and the theme's entry is simply gone:

wp eval '$m = WP_Theme_JSON_Resolver::get_merged_data()->get_settings();
echo implode(",", array_column($m["color"]["palette"]["theme"], "slug"));'
base,contrast,accent

Four slugs went in; three came out. In WP_Theme_JSON, the preset metadata carries a prevent_override key, and while core's defaults are enabled it is true for the palette, the gradients, the duotone set, font sizes, spacing sizes and shadows. When it is true, the merge runs a slug filter that drops every theme preset whose slug matches a default one, then prints the core value in its place. My first attempt used the font size slugs small, medium and large, and the front end printed core's 13px, 20px and 36px instead. Renaming them to caption, body and display made them appear.

Nothing warned me either time. The JSON was valid, the schema check passed, and the merge happened at runtime after the file was read. There is an escape hatch, and it is a decision rather than a workaround: setting settings.color.defaultPalette to false disables core's palette, and the filter is skipped so your slugs survive. That is a real choice with real consequences, not a flag to set because a colour did not appear.

This is the failure an agent is most likely to hand you on the block path, because core's default slugs are exactly the names a model has seen most often in training data. In WordPress 7.1.3 the reserved slugs are black, white, cyan-bluish-gray, pale-pink, vivid-red, luminous-vivid-orange, luminous-vivid-amber, light-green-cyan, vivid-green-cyan, pale-cyan-blue, vivid-cyan-blue and vivid-purple for colour, and small, medium, large and x-large for font size. Treat those as taken and choose theme-specific slugs for everything you define. Then verify by fetching the page and grepping the custom properties, which costs one command.

What a prompt cannot see on either path

Two failures are identical on both paths, and one is worse on the theme path.

The first is the build signal. If your theme ships JavaScript, wp-scripts build reports success for code that imports an API which no longer exists. The mechanism is the same as in the plugin case: @wordpress/* is externalised to a global, and the module name is never checked against the thing it resolves to. A theme makes this worse because the theme's own functions.php and templates are not part of that build at all, so the JavaScript half and the PHP half have different, non-overlapping verification stories.

The second is self-verification. When the agent cannot run WordPress, it writes wp_kses_post() and get_block_wrapper_attributes() itself, tests against those, and reports green. That tests its beliefs, not the framework. I reproduced the divergence with a stub that allows six tags instead of core's 124 and then run the same assertion through both paths:

stub harness : FAIL
real post    : PASS
real value   : use <div> and <code>

The stub reported a failure that did not exist and would have reported success for code that destroys legitimate markup. The moment the stub disagrees with reality, every green result it produced becomes worthless, and you have no way to tell which results those were.

The failure that is worse on the theme path is the one you cannot fix by running one more command. A plugin has a command and a page, so you can ask three separate questions and get three answers. A theme has no interface of its own: it is correct when the whole site looks right, in every template, with the plugins installed and the menu content present. Nothing in the toolchain compares the rendered page to the design you asked for. If your prompt said "clean editorial layout" and the theme produced something else that validates and renders, every gate stays green, and the only detector is a person looking at the site.

Same rules, different artefacts

WordPress's AI contribution guidelines apply to themes as much as plugins: disclose meaningful AI assistance, keep the licence GPLv2-or-later compatible, and expect that hallucinated hooks and unreviewed code dumps can be rejected. A generated theme is closer to a first draft from an unknown contributor than to a finished product.

Which path to pick, and the checks that decide it

Pick block when the site is new, when the person editing it uses the Site Editor, when you want the palette and type scale to be inherited by plugins rather than fought over, and when you want a theme whose templates are content rather than code. Pick classic when templates are already overridden, when a page builder owns the markup, when the theme wraps WooCommerce templates, or when the team maintains PHP and has no intention of adopting block markup.

Whichever you pick, the same four checks decide whether the result is real. Activate it and confirm is_block_theme() matches your intent. Fetch three different templates rather than the home page only, because a front page proves almost nothing about single.php or the 404. Grep the delivered CSS for the presets you defined, and treat a missing one as a collision until you have proven otherwise. And run the theme with the plugins the real site uses, because a theme that is correct on an empty install is a theme that has not been tested.

Where the agent helps is the boring majority of the work: the template set, the escaping calls, the enqueue functions, the header block and the naming consistency. Where it cannot help is knowing whether the merged result is what you wanted. That distinction is the useful one, and the WordPress development with AI agents course is built around putting a real WordPress in the loop so the merge can be observed instead of assumed.

You will need somewhere to run this. The loop above works on a local install, and it works equally well on a host with SSH and WP-CLI, which is how I would stage a theme before it meets a live site: build and verify locally, then push the theme directory and activate it in staging.

Some links on this page are affiliate links. If you sign up through one, WPSifero may earn a commission. It does not change what is recommended here, and nothing on this page was written by a host. Verify current prices on the provider's own site before you buy.

If you want a host that ships WP-CLI and staging on the plan rather than as an upsell, SiteGround's WordPress hosting is where I would put a theme in front of a real visitor for the first time. Buy the tier that includes staging, because activating a freshly generated theme on the site people are using is the one step in this workflow worth paying to avoid.

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

Related in WordPress Internals