How I Drive WordPress From Claude Code (REST, Playwright, wp-cli)
Driving WordPress from Claude Code (Anthropic’s terminal coding agent) fights the platform’s core assumption roughly every other operation. WordPress was built for humans clicking buttons. Last week I burned 90 minutes trying to make a “modern” REST API do the work of a 0.5-second Save Changes click. The REST API lost.
This is a tour of the three automation layers a WordPress Claude Code workflow needs, where each one breaks, and what I shipped to stop bleeding time on the same paper cuts. None of it is great. Some of it is acceptable. If you are about to drive WordPress from an agent, read this before you spend an evening rediscovering the pattern.
Why is WordPress hard for AI agents?
Every meaningful write path in WordPress was designed around a logged-in admin in a browser. That means cookies, CSRF (cross-site request forgery) tokens called “nonces,” and PHP hooks that only fire on real form submissions. The REST API was bolted on later and covers about 60% of the admin surface. The rest lives behind admin-only Asynchronous JavaScript and XML (AJAX) endpoints an agent cannot reach without impersonating a browser session.
The consequence: a routine write that should be one sub-second call has to degrade to a headless browser, and when that fails, to a shell on the host. Three layers, three credential stores, three failure modes. No layer covers the full surface alone.
I write, edit, and ship ianlpaterson.com using a Claude Code WordPress workflow that grew out of an Obsidian-to-WP pipeline. A typical week: publish a draft from Obsidian, patch SEO metadata on older posts, refresh a site-wide CSS block, sanity-check the sitemap, cross-post to dev.to and Hashnode. All scripted, no mouse required. About once a month, one of the three layers blows up and takes an afternoon to unpick. The slash commands save roughly an hour per publish batch on the weeks nothing breaks, so on a four-post-a-month cadence the net stays positive.
Update (2026-05-19): the post’s own thesis broke 30 minutes after publish
I was wrong about Layer 3. While running my /post-publish check on this article, I noticed it was not in https://ianlpaterson.com/post-sitemap.xml. The same frozen-cache bug this post documents, applied to the post documenting it. So I finally ran the wp-cli bypass I had hedged on in the body below:
ssh -p <port> user@host '~/bin/wp rankmath sitemap generate'
It busted the cache on the first try. post-sitemap.xml lastmod jumped to the current minute. This post appeared in the index.
The “Why no API path can fix this” H3 below was too absolute. A wp-cli path does fix it. The hedge paragraph in that H3 (“I have not yet verified”) is now verified, with the opposite conclusion to the H3 title.
The deeper miss: wp rankmath sitemap generate is a Rank Math wp-cli subcommand that calls the cache invalidation method directly. It bypasses the form-submit requirement because it is calling the cache method, not firing the hook through the admin UI. wp eval 'do_action("rank_math/settings/after_save");' also works, and so does calling the method directly: wp eval 'RankMath\Sitemap\Cache::invalidate_storage();'.
Layer 3 fixes hook-dependent caches when the plugin author has shipped a wp-cli command for it. Check wp <plugin> or wp help before declaring something unreachable. I missed this for 90 minutes during the original debug. I missed it again writing the article. The article's own publish pipeline disproved me 30 minutes after publish. The Playwright toggle-save is still a valid path; it is no longer the only one.
I am leaving the body below intact so the correction is legible.
Three layers: REST API, Playwright, SSH plus wp-cli
The three layers are REST API plus application password, Playwright into wp-admin, and SSH plus wp-cli. Access cascades from fastest to most invasive: each layer covers the cases the previous one cannot. The shape is not elegant. It is what emerges when each layer cannot finish the job.
Layer 1: WordPress REST API and application passwords
For my WordPress from Claude Code workflow, REST is the first thing I reach for. Each call is sub-second, well-documented, and atomic. Each call stands alone, so a flaky connection only loses the call in flight. A WordPress application password is a built-in per-script credential type meant for exactly this: a token stored in ~/.netrc so the secret never appears on a command line.
What works at this layer:
GET /wp-json/wp/v2/posts/{id}?context=editreturns the raw Gutenberg blocks. Thecontext=editparameter is mandatory; without it the API returns rendered HTML and silently strips block markup.POST /wp-json/wp/v2/postspublishes a new post.POST /wp-json/rankmath/v1/updateMetasets SEO title, description, and focus keyword. Standard REST endpoints silently ignore Rank Math’s fields. That cost me an hour the first time.GET /wp-json/wp/v2/medialists uploads.
What does not work:
- Anything that needs an admin nonce. Plugin updates, settings saves, cache invalidations, the Customizer. These hit
wp-admin/admin-ajax.phpwith a CSRF token issued only to a logged-in browser session. An app password gets nowhere. - Bulk operations against a slow plugin. Rank Math’s sitemap regenerator times out on the default REST timeout when the database is cold.
I keep all of this behind a /wp slash command in Claude Code that wraps a shared wp_lib.py. Typical commands:
/wp get gear
/wp seo set 1815 --title "..." --desc "..." --kw "..."
/wp publish ~/drafts/foo.md --as post
The skill handles auth, diff preview, and confirmation prompts. When it works, it is the fastest path. When it does not, the failure is silent: the call returns 200, nothing changed.
Layer 2: Playwright (when REST fails, click the button)
The second layer of a WordPress from Claude Code workflow is Playwright, the escape hatch when REST returns 200 but nothing changed. The agent logs into wp-admin with the same credentials a human would, clicks the buttons, waits for the toast notifications. Playwright is a browser automation framework that drives a headless Chromium against the real admin UI.
What works:
- Plugin updates. The Update Now button.
- Settings pages with Save Changes buttons. This is the layer that matters for the sitemap story below.
- Bulk post actions.
- Anything a human admin can click.
What does not work:
- The Gutenberg block editor on long posts. My threshold is around 3,000 words, varies by block count and plugin load. It loads fine, but the
wp:htmlblock parser stalls in headless mode and a Save click never registers reliably. - Elementor pages. Their editor is a JS app that does not paint in headless Chromium without a real display. My Elementor-built homepage is permanently off-limits to Playwright.
- The Customizer. Same JS-heavy reason.
Playwright is slow (30 to 60 seconds for a settings save) and fragile: one DOM change in a plugin update and the selectors break. It is a fallback, never a default. It is also, under the right circumstances, the only thing that works.
Layer 3: wp-cli over SSH (direct database access)
The third layer of a WordPress from Claude Code workflow is SSH plus wp-cli, which bypasses HTTP entirely. wp-cli bootstraps WordPress in-process inside a shell on the host, so a script can talk to the database without going through any web layer at all. It is the newest addition in my stack and the one that finally killed a class of REST-throttle bugs. Run wp-cli over SSH on shared hosting and a lot of the prior layers’ indirection just falls away.
This sounds like the obvious default. It is not, because on a shared hosting plan the setup costs are real:
- SSH is on a non-standard port. Most cPanel hosts list the actual port on the cPanel SSH Access page.
- The username is a short cPanel shortname, not the cPanel login email or the wp-admin user.
- The SSH key is RSA 2048 with a passphrase. Agent forwarding is disabled.
- wp-cli is not preinstalled. The fix is a one-time download of the phar (a PHP archive bundle),
chmod +x, drop it in~/bin/wp.
Total setup with an SSH key already registered: 30 seconds. Without one, a support ticket and a workday. Get this in place before anything is on fire.
What works at this layer:
- Direct option manipulation.
wp option get rank_math_options_sitemap,wp option patch update .... - Database queries that bypass the REST throttle.
- Plugin installs and updates that bypass nonces.
- Reading
wp-config.phpto confirm what plugins think the environment is.
What does not work:
- Anything that requires hooks to fire and that the plugin has not exposed via a wp-cli subcommand. wp-cli runs WordPress bootstrap, but it does not trigger the same
admin_initactions that a real admin page load does. The sitemap cache hook, for example, lives onrank_math/settings/after_save, which wp-cli does not fire when patching the option directly. (Update: Rank Math shipswp rankmath sitemap generate, which calls the cache method directly and bypasses this. See the Update section above.)
Three layers. Each fast at its own job, useless at the next layer’s. The gaps are not edge cases. They are how I lost an afternoon.
Rank Math sitemap stuck: the fix is a Save Changes click
Short version: the Rank Math sitemap cache is invalidated by exactly one thing, a real Save Changes click on a value that has changed, and none of the documented API paths trigger it. I lost 90 minutes proving this the hard way. Last week three new posts published cleanly but never appeared in /post-sitemap.xml; the lastmod was frozen at 2026-04-13, weeks before the most recent publish.
The diagnosis: the sitemap is a frozen cache snapshot
The first useful signal came from reading every per-URL <lastmod> value across the sitemap. Forty-seven URLs all stamped within a 4-second window on 2026-04-13. That is not a sitemap tracking real post_modified dates. That is a frozen snapshot.
The question changed from “why is this post missing?” to “what invalidates the snapshot?”
Five things I tried that didn’t work
Clear Rank Math transients via REST. Rank Math exposes a toolsAction endpoint that runs clear_transients. It returned “No Rank Math transients found.” Useful negative result: whatever holds this sitemap, it is not a transient.
**Toggle the sitemap module off and on via saveModule REST.** The documented way to bounce a Rank Math feature. It did nothing. Reading the source revealed why: the Cache_Watcher class that invalidates the sitemap cache is not wired to the module-toggle hook. Documented behavior; the code disagrees.
Touch posts via REST with a no-op content PUT. WordPress fires save_post on any update, and some plugins listen for save_post to clear sitemap caches. Rank Math does not. Cache stayed frozen.
Re-create Tables in Status and Tools. A Rank Math button for missing custom tables. Tables were not missing. No effect.
Plugin update from 1.0.263 to 1.0.270. Theory: maybe a schema change would force regeneration. Nope. The existing snapshot survived.
At minute 70, I was reading Rank Math source on GitHub looking for the hook that actually fires cache invalidation.
The fix: toggle a checkbox and Save Changes
The hook is rank_math/settings/after_save. The only thing that fires it is a real form submission from the Sitemap Settings admin page, with a value that has actually changed.
The fix is 30 seconds in Playwright:
1. Open wp-admin, Rank Math, Sitemap Settings. 2. Toggle Include Featured Images off. 3. Click Save Changes. The button is disabled until a value differs from stored, which is why a no-op submit cannot fire the hook. 4. Toggle Include Featured Images back on. 5. Click Save Changes again.
Right after:
curl -s "https://ianlpaterson.com/post-sitemap.xml?cb=$(date +%s%N)" | grep -c '<loc>'
The count jumped immediately. New post lastmod values tracked real post_modified again.
Why no API path can fix this
The cache lives in a non-transient option. Only Cache::invalidate_storage() clears it, only rank_math/settings/after_save fires it, and only a real Save Changes click fires that action. The button is disabled unless the form value differs from what is stored.
Update, 30 minutes after publish: wp rankmath sitemap generate does bust the cache. See the Update section near the top of the post. The “no API path” framing of this H3 is therefore too absolute. The wp option patch approach does NOT work (it does not fire the cache method), but the Rank Math wp-cli subcommand calls Cache::invalidate_storage() directly and bypasses the form-submit requirement. So does wp eval 'do_action("rank_math/settings/after_save");'. I missed both paths during the original 90-minute debug. The Playwright toggle-save remains a working fix; it is no longer the only one.
A 5-line mu-plugin that exposes Cache::invalidate_storage() via a custom REST route would also work; that is the right fix for a multi-site agency. For a single-author blog, toggle-save in Playwright is faster to ship and survives Rank Math updates without re-auditing the private API surface.
This is not unique to Rank Math. Every nontrivial WordPress plugin I have audited has a hook like this somewhere: a piece of state that only refreshes when a specific admin form fires. A supposedly modern toolchain (REST, Playwright, wp-cli, all wired through a slash command) cannot reliably do what a human clicking Save does in half a second. That gap is the cost of agent-driving the platform.
When to use REST vs Playwright vs wp-cli
| Layer | What it can do | What it cannot do | Cost of the gap |
|---|---|---|---|
| REST API + app password | Posts, media, Rank Math meta via custom endpoints, sub-second writes | Anything behind an admin nonce: settings saves, cache invalidations, Customizer, plugin updates | Silent 200s on writes that did not take; verify every change |
| Playwright into wp-admin | Plugin updates, settings pages, Save Changes buttons, bulk admin actions | Gutenberg on long posts (~3,000 words), Elementor pages, the Customizer | 30 to 60 seconds per save; selectors break on any plugin DOM change |
| SSH + wp-cli | Direct option reads/writes, DB queries that bypass REST throttle, plugin installs | Anything that needs admin hooks to fire (sitemap invalidation, settings-after-save callbacks) | Patches state without side-effects; hook-dependent caches stay stale |
| Composite (all three) | Covers the full admin surface if orchestrated correctly | Coherent orchestration: each layer has its own auth, failure mode, and latency | One afternoon a month spent rediscovering which layer owns which bug |
What about WordPress MCP?
The obvious question: doesn’t the WordPress MCP Adapter solve all of this? MCP, short for Model Context Protocol, is Anthropic’s open standard for wiring large language models to external tools. In February 2026 Automattic shipped a WordPress MCP Adapter that exposes WordPress core actions through that protocol, plus a separate Automattic plugin called wordpress-mcp. WordPress.com followed with native MCP support for hosted sites. On paper I expected this to be the answer.
In practice MCP sits on top of the three layers rather than replacing them. The adapter targets the same Abilities API that Gutenberg blocks and admin AJAX already lean on. When the underlying ability is broken or missing (the Rank Math sitemap invalidation hook is the canonical example), MCP cannot fire it either. The protocol is a clean transport. It does not retroactively fix the fifteen years of admin-only code paths that ship with every active plugin.
The other catch is reach. The Automattic plugin works on sites where the operator can install plugins. WordPress.com’s hosted MCP works on WordPress.com. Anyone running on shared hosting, Elementor-heavy sites, or a managed-host environment that forbids new plugins is still in the three-layer world for any plugin-side ability the upstream maintainer has not exposed. So is anyone whose agent is talking to multiple WordPress sites with mixed plugin sets.
I reach for MCP when all three of these are true: the site is on WordPress.com or self-hosted with admin access; the actions I need are core-shaped (posts, media, users, settings exposed through the Abilities API); the agent is a sustained interactive session rather than a one-shot script. For the rest, REST plus Playwright plus wp-cli is still the least-bad path. The two approaches compose. Nothing stops a workflow from reaching for MCP first and falling back to the three layers when MCP returns a “no ability registered” error.
What I shipped: /wordpress, /tssh, and a bats test suite
Two slash commands and a bats test suite. Both are scoped: each does one job and refuses to do the next layer’s.
/wordpress: my Claude Code slash command
/wordpress is a single Claude Code slash command with 16 sub-commands, each a thin audited wrapper around an SSH-plus-wp-cli incantation. The full list: sitemap-regen, cache-purge, plugin-list, plugin-update, error-log, db-backup, post-touch, shell, user-list, search-replace, cron-run, rewrite-flush, maintenance, option, theme-update, post-list.
Each sub-command follows the same shape:
1. Load the SSH key into a fresh ssh-agent. The Claude Code Bash tool does not persist agents across calls. 1Password supplies the passphrase via op, with the askpass file written through a heredoc to avoid printf format-string injection on adversarial passphrase content. 2. Probe for wp-cli on the host. Auto-install the phar to ~/bin/wp on first run if missing. 3. Run the wp command over SSH on the host’s configured port, with a wrapper that propagates non-zero exit codes loudly to stderr. No auto-retry: most of these are writes and silent retry is a foot-gun. 4. Verify by re-querying the live site: curl the sitemap timestamp, check the cache header, stat the backup file size.
The safety checks are not decoration. plugin-update --all refuses to run if Elementor is in scope, because Elementor breakage on a shared host eats a Saturday. search-replace dry-runs by default and requires --yes. db-backup warns if the gzip is under 100 KB, the typical signature of a failed export. post-touch refuses --post_modified directly: wp-cli silently ignores it, and a bare update is the only thing that fires the bump.
/tssh: the sandbox-meets-Tailscale escape hatch
This section is for readers who use Tailscale to reach a private host. Skip if your WordPress server has a public IP and you SSH to it directly.
A coordination bug surfaced between Claude Code’s Bash sandbox and Tailscale. Tailscale is a mesh VPN that puts every device on a private network using CGNAT (carrier-grade NAT, a private IP range like 100.64.0.0/10). The Claude Code sandbox runs Bash commands inside a network namespace. Tailscale’s TUN interface lives in the host’s namespace, not the sandbox’s, so ssh user@100.x.x.x returns “Network is unreachable” even when the Tailscale IP is in the sandbox’s allowed-hosts list. Two layers, each correct in isolation, wrong together.
The fix is a two-line bash wrapper at ~/.local/bin/tssh. It scans its arguments for a Tailscale CGNAT IP (regex against 100.64.0.0/10). If it finds one, it execs plain ssh. If it does not, it refuses with exit code 2, which keeps the unsandboxed scope honest: tssh cannot accidentally reach github.com or a public WordPress host.
Two settings in the Claude Code config plumb it through: a permissions allow rule for Bash(tssh *) so the permission prompt does not fire, and a sandbox-excluded-commands entry for tssh so the wrapper itself runs outside the network namespace. The escape hatch is named and scoped, and it stays out of the way until a Tailscale IP shows up.
Why I wrote bats tests for slash commands
Both wrappers ship with a bats test suite at ~/.claude/commands/wordpress.test/. Forty-three unit tests cover the safety paths (Elementor refusal, dry-run gates, exit-code propagation, askpass passphrase escaping) plus 5 static checks. The static checks catch dumb regressions like reintroducing em dashes into the skill prose, or letting a sub-command forget to call load_ssh_key. Future me, six months from now, will not remember why plugin-update --all refuses Elementor. The test will refuse to let me delete the refusal.
That is what “productized” means here. The fix is documented, the operation is repeatable, the safety properties are mechanically enforced. It is not the same thing as “WordPress is easy to drive from an agent now.”
Frequently asked questions about WordPress + Claude Code
How to automate WordPress posts?
Three approaches, picked by what you’re automating: REST API for fast atomic writes (posts, media, taxonomies); Playwright for actions behind admin nonces (plugin settings, cache invalidations); wp-cli over SSH for direct database manipulation. Most WordPress post automation uses the REST API; the other two are escape hatches for cases REST cannot reach. Mixing them is what makes the workflow ship.
How does WordPress REST API work?
WordPress exposes a REST API at /wp-json/wp/v2/ for posts, pages, media, and taxonomies. Authentication uses application passwords stored in ~/.netrc with chmod 600. The API covers about 60% of what an admin can do. Settings pages, cache invalidations, and most plugin-specific actions live behind admin nonces and need Playwright or wp-cli.
Why is my Rank Math sitemap not updating after I publish a new post?
The sitemap is cached in a non-transient option, and only Cache::invalidate_storage() clears it. That call only fires on the rank_math/settings/after_save hook, which only fires when a Save Changes click submits a value that has actually changed. Clearing transients, toggling the sitemap module, plugin updates, and post touches all do nothing. The proven fix is toggle Include Featured Images off, save, toggle back on, save again.
Can I use REST API with WordPress?
Yes. WordPress exposes a built-in REST API for most reads and writes on posts, pages, media, and taxonomies. Use application passwords for authentication. The catch: settings pages, cache invalidations, and most plugin-specific actions are gated behind admin nonces (anti-CSRF tokens issued only to logged-in browser sessions) and are not reachable via REST. For those, you need either Playwright (Layer 2) or wp-cli over SSH (Layer 3).
How do I run wp-cli over SSH on shared hosting?
wp-cli is not preinstalled on most shared hosts. Download the phar from getwpcli.org, chmod +x, drop it in ~/bin/wp. Total setup is 30 seconds if an SSH key is already registered. On a typical cPanel-managed host, the SSH port is non-standard and the username is the cPanel shortname rather than the cPanel login email; check the SSH Access page in cPanel for the actual values. Agent forwarding is usually disabled, so key passphrases need a vault-backed askpass.
What is MCP in WordPress?
MCP (Model Context Protocol) is Anthropic’s open standard for wiring LLMs to external tools. Automattic shipped a WordPress MCP Adapter in February 2026. It fits if you control the host (WordPress.com or a self-managed install). For shared hosting and legacy sites, the three-layer cascade still wins. See the WordPress MCP section above for the full breakdown.
Is WordPress good for AI?
Mixed. The REST API covers content workflows (posts, media, taxonomies) cleanly. Anything behind admin nonces (settings, cache invalidations, the Customizer) needs a headless browser or shell access. For greenfield AI-driven sites, headless WordPress or an API-first CMS is cleaner. For legacy WordPress on shared hosting, the three-layer cascade is the working answer.
Five lessons from agent-driving WordPress
A few patterns I now follow. They do not make the platform good. They make working around it survivable.
I start with REST, not SSH. REST handles roughly 80% of my weekly edits, faster and with less surface area. I escalate to Playwright or wp-cli only when REST fails closed.
For settings-store bugs, I trust the source code over the docs. The Rank Math docs made it sound like clearing transients or toggling modules invalidates the sitemap cache. The source said only after_save does. The docs were not lying; they described what should work, not what the code did. Reading the actual hook registrations is what unstuck me.
I stage SSH and wp-cli before I need them. My shared host does not ship wp-cli. The phar download is 30 seconds. The credential setup, if no SSH key is registered yet, can be a day. I would rather not be in a support ticket queue during an outage.
I treat admin Save buttons as real APIs in disguise. When a plugin stores its config in WordPress options, the Save handler is often the only documented invalidation path. In those cases the HTTP API is the button itself, even if no public docs say so. This pattern has shown up in more of the plugins I have automated against than I expected.
I bake safety into a slash command at the start, not after the first incident. Dry-run mutations, refuse high-risk ops by default, propagate remote exit codes loudly. The work of writing tests for these properties costs about 30 minutes and prevents the silently-succeeded class of bug, which is the worst class of bug to debug after the fact.
The honest summary. For my workflow (solo-author blog, Rank Math, shared hosting, agent-first writing pipeline) I would not pick WordPress again starting today. Larger teams with dedicated infra and headless setups have a different cost calculus, and plenty of agencies automate WordPress at scale just fine. The slash commands and the bats suite exist because I have 15 years of writing under ianlpaterson.com and migrating is not worth the disruption. For a solo writer on a fresh start: this is the least-bad path through a platform that was not built for agents, and the least-bad path is still uphill.
How a CEO uses Claude Code and Hermes to do the knowledge work
A blank or generic config file means every session re-explains your workflow. These are the files I run daily as CEO of a cybersecurity company managing autonomous agents, cron jobs, and publishing pipelines.
- CLAUDE.md template with session lifecycle, subagent strategy, and cost controls
- 8 slash commands from my actual workflow (flush, project, morning, eod, and more)
- Token cost calculator: find out what each session is actually costing you
One email when the pack ships. Occasional posts after that. Unsubscribe anytime.