Custom Blog Management (CBM)
A single WordPress install that publishes to many customer domains — authored once, rendered server-side, and served at each domain's own edge by a Cloudflare Worker.
- Client
- EpsolDev
- Category
- Platform Engineering
- Completed
- 2026-08-14
- Built with
- WordPress, PHP, React.js, TypeScript, Vite, Cloudflare Workers, MySQL, REST API
- Topics
- API Development, Backend, CMS, DevOps
WordPress Plugin Cloudflare Worker MCP Server In production
Running content for several client domains normally means one WordPress install per site. Ten clients means ten sets of updates, ten places for SEO markup to drift apart, ten cron schedules to babysit — and still no single answer to the only question that matters: which of our pages are actually indexed?
CBM collapses that into one control plane. A post is authored once, stored centrally, rendered server-side, and served under each client's own domain by a Cloudflare Worker.
The central install never appears in a URL. Readers see
client-domain.com/blogs/the-post, Google indexes that domain, and the content is still edited in exactly one place.
Architecture at a glance
| Layer | What it is | Notes |
|---|---|---|
| Authoring | React 18 + TypeScript SPA inside wp-admin | 26 routes, CodeMirror 6 editors |
| Storage | 5 custom post types, 11 custom tables | 40 sequential migrations |
| Rendering | Server-side, PHP | Markdown body + template + auto JSON-LD |
| Delivery | One Cloudflare Worker, many zones | Routes provisioned per client via API |
| Automation | 21 cron jobs, 241 MCP tools | Sync under a ceiling, queued above it |
| Contract | 2 REST namespaces, 248 route registrations | 28 controllers |
Author once, render anywhere

A post body is Markdown. A site binds to a template, and templates come in two kinds — single-post and blog-index — organised into folders per client, with an optional ZIP upload for asset bundles.
Templates are resolved by a Handlebars subset written from scratch in PHP. No Composer, no LightnCandy, no template compiler:
// Innermost-first passes, repeated until the template stops changing.
// Arbitrary nesting works without ever building an AST.
do {
$before = $tpl;
$tpl = $this->pass_conditionals($tpl, $ctx);
$tpl = $this->pass_loops($tpl, $ctx);
$tpl = $this->pass_variables($tpl, $ctx);
} while ($tpl !== $before);
It supports {{var}}, {{{raw}}}, dot paths, #if / #unless / #each with {{else}}, {{@index}} and registered helpers. The reason to own ~300 lines instead of pulling a dependency: this runs inside someone else's WordPress, where a Composer autoloader is a conflict waiting to happen.
On top of the body, 20 JSON-LD schema types are injected automatically based on what the content actually contains:
Article · BlogPosting · FAQPage · HowTo · Product · Review · Event · Organization · Person · LocalBusiness · WebPage · Speakable · BreadcrumbList · TouristAttraction · TouristTrip · VideoObject · AggregateRating · Author — plus a detector, a registry and a builder.
The edge layer

One Worker script serves every zone. The wrangler.toml deliberately contains no route blocks — routes for /blogs/*, /sitemap-blogs.xml and the IndexNow key file are created per client zone through the Cloudflare API from the admin UI. Onboarding a domain is an API call, not a redeploy.
The plugin can even deploy its own Worker: a bundled ESM build is uploaded to the client's Cloudflare account, but only when the script is missing or its content hash changed.
Cache behaviour
| Response | Fresh | Stale-while-revalidate |
|---|---|---|
200 HTML / XML | 1 hour | 23 hours |
404 | 60 seconds | 5 minutes |
Origin 5xx or network error | — | serves stale with X-CBM-Stale |
The cache key includes the request host, so two client domains can never collide on the same path.
Two details that came from real bugs
Canonicals are redirected, not rewritten. An earlier rewrite-based approach returned 200 on three different URL shapes for the same content, and Search Console duly reported "Alternate page with proper canonical tag". The Worker now issues a 301 at the edge.
The Worker announces itself. Every response carries x-cbm-worker and x-cbm-worker-version, because cf-ray only proves a Cloudflare Worker answered — not that this one is on the route. The admin's route probe reads that header.
Drift detection never self-heals A daily job compares each site's expected Worker routes against live Cloudflare state and writes a report. Repair stays an explicit operator action — an automated fixer that is wrong at 3am is worse than a red badge on a dashboard.
Getting indexed, and knowing whether you are

Publishing is the easy half. The hard half is proving a URL made it into the index.
Submission
| Engine | Mechanism |
|---|---|
| Bing, Yandex, Naver, Seznam | IndexNow |
| Indexing API, RS256 JWT service-account OAuth | |
| Bing | Webmaster SubmitUrlBatch |
| Baidu | 主动推送 push API |
Each engine runs inside its own try/catch, so one engine failing cannot take down the batch. Submission fires automatically on publish, and sends URL_DELETED on unpublish.
Daily quota guards stop before the ceiling rather than after it:
- Google skips at 180 of 200
- Bing skips at 90 of 100
Bulk runs go through a background queue that a 5-minute cron paces across engines, rather than firing everything at once.
Coverage
A per-URL store tracks a six-state model, rechecked hourly, with capped re-submission back through the same quota-guarded queue:
indexed— confirmed presentnot_indexed— confirmed absent, eligible for re-submitpending— submitted, not yet checkeduncertain— checker returned an ambiguous verdictstalled— repeatedly checked, never resolvederror— the check itself failed
Local search
Rank Tracker plots a Google Business Profile's local-pack position as a geo-grid heatmap — every keyword measured at every grid point, rolled up into a visibility score.
The interesting constraint is cost and latency. The SERP provider's live endpoint handles roughly one task per request, so a 7×7 grid across 5 keywords is 245 separate calls. Firing them sequentially would take minutes:
// One request per (keyword × grid point), issued concurrently
// under a fixed concurrency cap. An N-point scan costs about
// ceil(N / concurrency) round trips instead of N.
$mh = curl_multi_init();
Scans under 16 calls run synchronously; anything larger drains through a background queue with a live progress bar, under a hard cap of 1000 calls per scan.
Built to be driven by machines

Every operation the admin UI can perform is also callable by an AI agent or an automation. The MCP server exposes 241 tools from an auto-discovering registry — drop a file into the tools directory that registers itself at file scope and it appears in tools/list. No central manifest to update, nothing to forget.
{ "jsonrpc": "2.0", "method": "tools/list", "id": 1 }
Authorisation
- OAuth 2.1 authorization server and resource server on the same WordPress origin. The issuer deliberately carries the
/wp-json/path, because some hosts' webserver answers/.well-known/*before WordPress ever sees it — with an auto-provisioned rewrite as a fallback. - Opaque bearer tokens shaped
cbm_mcp_ID.MAC, where the second half is an HMAC integrity check independent of storage. The server persists only a SHA-256 hash; the plaintext is shown exactly once, at mint time. - Tokens can be pinned to a single client site. Tools stay visible to unpinned tokens and are gated at call time inside each adapter — a deliberate choice, so an agent planning a workflow can see what exists and gets a clear refusal rather than a mysteriously empty catalogue.
Connections

Twenty third-party services are managed from one screen, each with a live status and a scope — global, per-site, or both. Press [[Test all]] and every credential is re-checked in place.
Cloudflare · Google Search Console · Google Business Profile · Google Drive · Google Photos · Google Places · DataForSEO · n8n · Bing Webmaster · IndexNow · Instagram · Facebook · YouTube · LinkedIn · TikTok · X · Cloudinary · Perplexity · GitHub Pages · S3-compatible storage · SMTP
Credentials never round-trip. Secrets are encrypted at rest with libsodium crypto_secretbox, falling back to AES-256-CBC, with keys derived from the WordPress salts and blobs tagged so both encodings coexist during rotation. Read paths mask; nothing decrypted is ever returned to REST, MCP or the SPA.
Decisions worth naming
Heavy work degrades to a queue instead of timing out. Three subsystems share one shape — a synchronous ceiling, a chunk size, and a hard cap:
| Subsystem | Runs sync under | Chunk | Hard cap |
|---|---|---|---|
| Rank Tracker | 16 calls | 16 | 1 000 |
| Local SEO Projects | 400 items | 250 | 1 000 |
| Mass Page Builder | 60 pages | 25 | 2 000 |
Past the ceiling the job drains through a 5-minute cron with live progress. The caps are not arbitrary — at roughly $0.002 per SERP call, 1000 calls is about $2, so the ceiling is a budget guard as much as a timeout guard.
Per-feature LLM routing. Model choice is resolved per AI feature — blog generation, hero imagery, rank insights — with a three-step fallback and capability metadata attached to each feature. That metadata is what stops a provider that can only run asynchronously from being routed to a synchronous consumer. Keys are decrypted inside the resolver and never logged or returned.
Cache-busting is part of the build contract. Vite hashes the entry filename, not just chunks, and PHP resolves it from the build manifest. A stable filename meant browsers served stale bytes that imported old chunk hashes, React loaded twice, and reconciliation crashed. A server-rendered skeleton covers the gap before the deferred module executes.
Two-tier cache invalidation. Saving a post purges that post's URLs. Zone-wide purge exists, but it lives behind an explicit button on the Tools page — never on a content hook.
Scale
| PHP source | 455 files, 85 service classes |
| Data | 11 custom tables, 40 migrations, 5 post types |
| API | 2 namespaces, 28 controllers, 248 route registrations |
| Admin | 26-route React SPA, 162 components and hooks |
| Automation | 21 recurring jobs, 241 MCP tools |
| Integrations | 20 connected services |
| Tests | 66 test files |
Counts verified by file and registry enumeration at v3.299.0.
Stack
PHP 8.1 WordPress 6.5 React 18.3 TypeScript 5.6 Vite 6 TanStack Query 5 React Router 6 CodeMirror 6 Cloudflare Workers MySQL Playwright
Styling is hand-written CSS with design tokens — no utility framework.
The automation layer
CBM does not generate content in-process. It exposes the control-plane endpoints — claim a run, fetch prompts and topics, check a title for duplicates, upload media, publish, write a run log — and an automation platform drives them from outside.
That split is the reason one pipeline can serve every client site: adding a site is a configuration row on this side, and nothing changes on the other. It is also why improving a prompt never touches PHP.
| Pipeline | What it drives |
|---|---|
| Blog Automation Engine | research, draft, illustrate and publish — per site, every six hours |
| Social Distribution Network | the published post out to five social platforms |
| Search Indexing Suite | submission at a survivable rate, then proof of coverage |
| Local Rank Tracking API | local-pack position feeding the geo-grid heatmap |
One architecture, three instances
CBM is not a one-off. The same skeleton — convention autoloader, singleton loader, a React SPA mounted inside wp-admin, a capability-gated admin REST namespace beside a public consumer namespace, and an in-plugin MCP server — has been instantiated three times for three different problem domains. The lineage is recorded in the code itself, not reconstructed after the fact: the commerce plugin's header names this one as its sibling, and several of its files carry explicit "ported from" and "mirrors" comments.
| Instance | Domain | Shape |
|---|---|---|
| CBM (this project) | Multi-site blog publishing | many client domains, one control plane, edge-rendered |
| Custom Store Management | Single-store commerce | catalogue, orders, payments, returns, storefront assistant |
| EpsolDev Management | Agency site backend | the smallest and cleanest, written last |
The storefront that consumes the commerce API is Luxwearz.
Lessons travel between them in both directions. A cookie-scoping bug found on this plugin's CDN zone is cited by name in the commerce plugin's OAuth implementation, and the encryption helper in the agency backend is a direct, credited port of the commerce one. Where they diverge, that is documented too — this one relays AI work through an automation platform, the commerce plugin calls providers directly.