My fantasy football side project had four boring problems that kept hurting SEO and the weekly pages: stale timing copy in old posts, theme-only tool routes inheriting the homepage title, a www host that still answered with 200, and weekly pages that were either redirected away or stuck on old theme markup.
This time I did not open the editor myself. I handed the job to an AI coding agent working from a sandboxed Linux box with a shell, curl, and the same WordPress admin paths I would use. My part was the rules: back up before you deploy, dry-run before you apply, keep a rollback, and prove every change with a request I can re-run. The result is a small set of self-hosted WordPress pieces that keep updating themselves. There’s no theme fork and no made-up Lighthouse scores.
The stack (generic on purpose)
- Self-hosted WordPress behind nginx, managed through a hosted nginx control panel with a page cache in front
- An SEO plugin for titles, schema, sitemaps, and 301s
- One MU-plugin for virtual-route titles and descriptions, plus adding the tools and news sitemaps to the sitemap index
- One live-pages plugin that server-renders the weekly picks, team news, and a stats table from a public sports API
- One operator plugin with Dry Run → Apply → Rollback for a content prune and merge (deactivated after the ship)
- One server-level nginx rule that sends every
wwwpath to the apex with a single 301 - The AI coding agent, which wrote the plugins, ran the checks, and wrote up what changed after every step
How I split the work with the agent
The agent is fast and confident, which is exactly why I gave it a fixed loop:
- Crawl first. It fetches the live URLs and sorts problems into three buckets: content (fixable over REST or in the editor), virtual routes (the theme renders them and they aren’t WordPress pages), and theme-injected markup.
- Build locally. Plugins are written and linted (
php -l) on the box before anything touches production. - Back up, then deploy. The previous plugin version is kept so a rollback is one upload.
- Verify with curl, not screenshots. Status codes,
Locationheaders, titles, and the sitemap index are checked against what I asked for. - Ask before anything irreversible. Deleting or merging content goes through a dry run I can read first.
That loop comes from my 7 rules for letting an AI agent maintain your homelab, applied to a production WordPress site this time instead of a home server.
What the agent actually shipped
1. Live pages plugin (shortcodes and cache)
Shortcodes live on fixed slugs instead of a new URL every week:
- A weekly picks page with a shortcode that ranks options for the current round
- A team news page, either as a full shortcode page or in an “extras” mode that drops into the existing theme template
- A stats table embedded in an evergreen guide
The plugin fetches the public API server-side and keeps a short transient cache. If the API blips it falls back to the last good copy, and a lock stops parallel requests from stampeding the API. The SEO title and description pick up the current round automatically, and the FAQPage JSON-LD is printed from the same data as the visible FAQ so the two can’t drift. The core fetch pattern looks like this:
function lp_get_api( $url, $key, $ttl = 300 ) {
$data = get_transient( $key );
if ( false !== $data ) {
return $data;
}
// Someone else is refreshing: serve the last good copy.
if ( get_transient( $key . '_lock' ) ) {
return get_option( $key . '_last_good', array() );
}
set_transient( $key . '_lock', 1, 30 );
$res = wp_remote_get( $url, array( 'timeout' => 10 ) );
delete_transient( $key . '_lock' );
if ( is_wp_error( $res ) || 200 !== wp_remote_retrieve_response_code( $res ) ) {
return get_option( $key . '_last_good', array() );
}
$data = json_decode( wp_remote_retrieve_body( $res ), true );
if ( ! is_array( $data ) ) {
return get_option( $key . '_last_good', array() );
}
set_transient( $key, $data, $ttl );
update_option( $key . '_last_good', $data, false );
return $data;
}
// Example: lp_get_api( 'https://api.example.com/v1/bootstrap/', 'lp_bootstrap' );
2. MU-plugin for virtual routes and the sitemap index
A handful of tool URLs are virtual routes that the theme renders. They aren’t WordPress pages, so they were inheriting the homepage meta. The MU-plugin gives each one its own title and description, and adds the existing tools and news sitemaps to the sitemap index next to posts, pages, and authors. If your SEO plugin is Rank Math, the hooks look roughly like this:
$routes = array(
'/tools/compare/' => array( 'Compare Tool | Example', 'Compare two options side by side.' ),
'/tools/planner/' => array( 'Planner | Example', 'Plan the next few rounds.' ),
);
$path = trailingslashit( wp_parse_url( $_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH ) );
add_filter( 'rank_math/frontend/title', function ( $t ) use ( $routes, $path ) {
return isset( $routes[ $path ] ) ? $routes[ $path ][0] : $t;
} );
add_filter( 'rank_math/frontend/description', function ( $d ) use ( $routes, $path ) {
return isset( $routes[ $path ] ) ? $routes[ $path ][1] : $d;
} );
add_filter( 'rank_math/sitemap/index', function ( $xml ) {
foreach ( array( 'tools-sitemap.xml', 'news-sitemap.xml' ) as $map ) {
$xml .= '<sitemap><loc>' . esc_url( home_url( "/$map" ) ) . '</loc></sitemap>';
}
return $xml;
}, 11 );
Other SEO plugins have equivalent filters. The point is that the fix sits in an MU-plugin and the theme stays untouched.
3. Prune and merge with Dry Run → Apply → Rollback
Evergreen topic clusters each got one keeper post, with 301s from the duplicates. Last season’s weekly posts now point at the live pages or tools. The agent built this as a one-shot operator plugin:
- Dry Run lists every post, meta, and redirect write it would make. I read that list before anything changes.
- Apply works in chunks under a time budget, so a big pass can’t hit the PHP timeout halfway through.
- Rollback reverses the file, post, meta, and redirect writes from its own log.
Once everything was verified the operator was deactivated. The redirects and keepers stay, and the one-shot applicator doesn’t keep running.
4. Close the www duplicates at the server, not in WordPress
WordPress’s canonical redirect already sent normal pages from www to the apex. The theme’s virtual routes and the sitemaps still answered 200 on www, because they never reach redirect_canonical. The fix is one server-level rule:
if ($host = "www.example.com") {
return 301 https://example.com$request_uri;
}
On a managed nginx panel this usually goes in a custom config include that loads before the main location block. Save it, let the panel run nginx -t and reload, then purge the page cache. Check that the TLS certificate covers both the apex and www before you rely on the redirect.
What broke (and how the agent caught it)
- The
wwwhost once answered with the wrong certificate. After that was fixed, normal pages redirected but the virtual routes and sitemaps still served onwww. The agent caught it by curling every route on both hosts, not just the homepage. - The new picks page URL was 301ing to an old slug (
x-redirect-by: WordPress), so an old slug redirect was sitting in front of the new page. TheLocationheader gave it away immediately. - Timing copy in old posts and injected SEO blocks still described a schedule the game no longer uses.
- The homepage and a few tool pages had a second
<h1>from a content injector. Demoting those to<h2>left one real H1. - The sitemap index listed only posts, pages, and authors. The tools and news sitemaps already existed but were missing from the index.
Step by step (operator order)
- Back up the site (panel backup or your usual plugin).
- Drop the MU-plugin into
wp-content/mu-plugins/, then re-save your SEO plugin’s sitemap settings to flush the index cache. - Install and activate the live-pages plugin. Free up the target slug if an old redirect still claims it. Publish the shortcode pages.
- Run the operator’s Dry Run, read the list, Apply, and draft the redirected posts so they drop out of the post sitemap.
- Add the
www→ apex nginx rule and purge the page cache. - Re-run the checks below.
- Deactivate the one-shot operator plugin, and delete it once you’re happy.
The checks the agent re-runs after every change:
curl -sI https://www.example.com/tools/compare/ | grep -iE '^(HTTP|location)'
curl -s https://example.com/sitemap_index.xml | grep -oE '(tools|news)-sitemap\.xml'
curl -s https://example.com/picks/ | grep -o '<title>[^<]*'
Ship checklist
- Have the agent sort content fixes apart from virtual-route and theme-injected problems before it writes any code.
- Prefer a shortcode plugin plus an MU-plugin over editing the theme in place.
- Give every bulk pass a Dry Run and a Rollback before you trust Apply on production.
- Don’t rely on WordPress
redirect_canonicalfor theme routes. Putwww→ apex in nginx. - Purge the page cache after every activation, deactivation, or nginx change.
- Make the agent show you the request that proves each fix, and never invent traffic or score claims in the ship notes.
FAQ
Why not one giant plugin for SEO, live pages, and redirects?
I wanted each layer to be something I could switch off on its own. The live-pages plugin stays on and so does the MU-plugin. The Apply/Rollback operator is a one-shot and shouldn’t keep running. Small plugins are also easier to review when an agent wrote them.
Did the agent have free rein on production?
No. It worked through the same admin and REST paths I would use, it backed up before each deploy, and every bulk change went through a dry run first. Anything that deletes, merges, or publishes waits for a human.
Do I need a local GPU for this?
No. This ship is a coding agent, self-hosted WordPress, nginx, and a public API. If you want the local-model side, my local LLM homelab stack post covers what I keep running.
Closest related Builders notes?
I Built an Admin-Only WordPress CRM With Cursor covers another AI-assisted admin plugin, and I Let an AI Agent Patch a Theme With a Tiny Reversible Plugin covers the “plugin over theme edit” rule.
