Use when writing or editing blog posts, hub or spoke pages, SEO metadata, GBP/social content, or the autonomous content engine — truth rules, prohibitions, hub-and-spoke boundaries, publish gates.
Resources
1Install
npx skillscat add wavespestcontrolfl/waves-customer-portal/waves-content Install via the SkillsCat registry.
Waves Content — publishing rules for the hub, spokes, blog, and social
Purpose
Content ships autonomously here (the portal's content engine writes and
merges blog posts with Codex review as the only gate), so the rules that
keep it truthful, compliant with owner directives, and SEO-safe must be
applied at write time. The astro repo's own CLAUDE.md is authoritative for
build mechanics (tokens, collections, redirects, sitemap) — this skill
layers the business rules and cross-repo workflow on top; don't duplicate it.
Brand voice, positioning, and conversion-writing reference: EXAMPLES.md in
this skill.
When to Use
- Authoring/editing anything in the astro repo's
src/content/or the
portal's content engine (server/services/content*, seo services). - SEO audits, metadata changes, internal-link work, GBP posts, social
content, newsletters.
Truth rules (non-negotiable)
- Real E-E-A-T only: Adam Benetti, founded 2024, FDACS license
JB351547 (licensed in every FL pest-control category EXCEPT
fumigation). Never a 2014 founding, invented tenure (SWFL tenure is only
since 2024 — noyears_swfl/years-of-experience claims, ever), or afact_checked_byattribution;technically_reviewed_by: Adam+ thefact_checkeddate stay. - Company name in copy is "Waves Pest Control" — never
"Waves Lawn & Pest". The mascot logo artwork carrying the old name is
current and intentional; never flag or swap the asset. - Pesticide compliance idiom: no product is ever "safe" (incl.
"pet-safe"/"family-safe"); "EPA-registered" or "EPA-exempt", never
"EPA-approved"; NEVER a fixed re-entry/drying minute figure — the idiom
is "safe once dry" + technician confirms timing. Applies to portal
surfaces too (estimator benefit lines, prep guides, reports). When one
instance of a banned claim class is flagged, sweep the WHOLE tree for
the class. - Every local/pest claim grounds in the facts bank
(astrocontent-ops/facts-bank/) or a citable source — no invented
county stats, pest seasons, or "customers report…" claims. - Verify links by their actual
href, not label text; verify a blog URL
against the live sitemap before "fixing" it (root-level.mdfiles still
route to/{category}/{slug}/by frontmatter).
Prohibitions registry (owner directives — do not relitigate)
- No near-me/transactional phrasing in blog posts (blog = informational
lane). Near-me terms are INTENTIONAL on service/city pages — including the
~2,000-char city-page metaTitles; never "fix" those. - No door-to-door sales content. Ever.
- Blog topic targeting (owner rulings 2026-08-27, after astro #476 Tampa /
#490 second-Taexx / #491 "… in Florida"): a NEW supporting blog may not be
built around (1) an out-of-footprint geo in its keyword/title/slug —
educational mentions are fine, targeting Tampa demand is not; (2)
statewide-only framing — "… in Florida"/"FL" with no served city or SWFL
anchor is too broad (localize or drop the qualifier; bare statewide GSC
demand is fine, the writer localizes it); (3) an entity a live post already
owns — one entity, one post; grow it as a REFRESH of the owner, never a
sibling (the in-wall post owns "Taexx"). Enforced deterministically bytopic-targeting-gate.js: runner step 2d pre-draft (silent skiptopic_targeting:<CODE>, corpus-unavailable → review), post-draft check on
the writer's own title/slug framing AND emitted primary_keyword ownership
(one redraft, then skip), GSC miner (out-of-area demand →do_not_publish), and every legacyblog_postsproducer — idea lane,generatePost(block de-queues the row: status → idea, reason inastro_publish_error),publishAstro(BLOG_TOPIC_TARGETING_BLOCKED,
parked as deterministic). Already-live rows are refreshes → exempt.
Ownership = a proper-noun token from ANY candidate targeting field —
primary keyword, title, slug, meta description, secondary keywords, H2/H3
headings (the same set the corpus index counts) — that ≤3 live posts carry
in their targeting fields and the owner carries ≥3×, same category (unknown
category → every category, owners unioned). Moving an owned entity into a
"secondary" field does not evade it. A row/brief city must be a served
locality or SWFL region. The gate also re-runs on the branch file right
before every merge (mergeAstro + the autonomous poller). Geo
coverage = the canonical content-guardrails blocklist + any other US
state/postal abbreviation; common-word place names (Homestead, Boston…)
count only with geo context and stay OUT of the prose blocklist. Persisted
per run inautonomous_runs.topic_targeting_result. - No fumigation, insulation, or wildlife-trapping content (rodent stays
core). - No bulk FAQ frontmatter expansion on the remaining service spoke pages.
- Social/GBP content is non-promotional value-first: DIY tips, facts,
holidays, humor; brand in the footer only; no phone/CTA headlines;
uplifting, never scary; grounded in SWFL. - Never hardcode WaveGuard/dollar amounts on marketing pages — link
/pest-control-calculator/. - Meta-description contract (owner rule 2026-07-29), 160 rendered chars
max for everything: NON-BLOG pages — any new or rewritten meta must
contain the{{cityPhone}}token, never a typed-out number (many
tracking numbers; the token renders the number that belongs to that
page/domain). BLOG posts — NO phone at all and nothing salesy;
informational summary ending with a soft CTA like "Learn more on the
Waves blog." Enforced everywhere the engine writes metas: quality gatemeta_phone_token_present(metadata lane, target-aware; unresolved
targets PARK) +meta_phone_token_presenton the city-service bundle +blog_meta_contracton the supporting-blog bundle (new drafts) +
renderedmeta_length_in_bounds; guardrailsMETA_MISSING_PHONE_TOKEN/LITERAL_PHONE_IN_META/BLOG_META_CARRIES_PHONE/BLOG_META_SALESY
/BLOG_META_MISSING_SOFT_CTA/META_OVER_160_RENDERED(refresh lane,
changed metas only — untouched legacy metas are grandfathered). - Protected URL families:
/pest-control-{city}-fl/and the-quote-
families take no 301/canonical/title changes without per-URL owner
sign-off; city×service pages with >5K impressions follow the same
per-URL rule. The/pest-control-services-{city}-fl/variants are NOT
protected — 301 them to the hub family (settled; don't re-ask). - GBP imagery: NO AI-generated imagery on GBP, ever (owner rule);
social images strip EXIF GPS before upload. KNOWN GAP (2026-07-29): the
live GBP publishers still auto-generate images (social-media.jsgbpWantsImagepath + the autonomous GBP action) — a code-side fix is
owed; never extend that pattern, and flag any diff adding AI imagery to
a GBP path. - Termite copy rulings: the warranty is an OPTIONAL annual renewable
bond — NEVER "included in the first year". "No contracts" is a GENERAL
brand promise allowed on termite pages, but guarantee/re-treat promises
stay OFF termite (re-treatment requires the paid bond). NO/termite-bait-stations/URL — program content lives on the city pages.
Comparison claims use sourced label facts only (never "requires 2×
stations").
Hub-and-spoke boundaries
- All blog lives on the hub. Spoke
/blog/renders empty by design;
spoke blog URLs 301 to the hub. Reversing this needs Adam's explicit OK. - Spokes are Waves-branded keyword sites with their OWN address and no
GBP — never publish the LWR HQ address or GBP sameAs on a spoke. - Brand isolation is token-based (
{{brandName}},{{siteUrl}},{{cityPhone}}…) and CI-enforced; never hardcode the hub brand into
spoke-shared content, and never fix a leak with an invisible build-time
rewrite. - Spoke-canonical pages are never re-rendered on the hub; hub orphans get
blanket 301s. Exact-match 301s live in the Cloudflare account Bulk
Redirect list (waves_redirects), NOT the repo_redirectsfile (the
deployed hub_redirectsalready runs ~96 of the 100-rule Pages cap). - Linking policy: spokes link to the hub ONLY from blog content —
spoke service/city pages never link the hub; the hub links out to spokes
ZERO times. Breadcrumbs build from the SERVED domain (Astro.site),
never the canonical.contact@wavespestcontrol.comis the ONE permitted
Waves-domain string in spoke rendered HTML. - GBP truth source is
server/config/locations.js(WAVES_LOCATIONS):
4 GBPs cover 5 staffed cities (the LWR GBP is shared with Bradenton);
North Port / Port Charlotte / Palmetto are service-area-only with NO GBP
— never invent one. The hub homepage HQ address is intentional
back-office (NOT a NAP mismatch), and the org-level schema node carries
noaggregateRating(ratings live on the 4 branch LocalBusiness nodes).
Publishing procedure (blog)
- Draft per the astro CLAUDE.md frontmatter/component rules; the binding
schema gate ispackages/blog-schema/schema.json(meta description
115–160 chars, 23 required fields, additionalProperties false). npm run publish:post <file>before merge;npm run validate:generated-blog -- --slug=<slug>after build. The
ASTRO repo is upstream for the blog schema: the portal'spackages/blog-schemais a VENDORED copy pulled from astro vianpm run sync:blog-schema(packages/blog-schema/scripts/sync-from-astro.js; a checksum
drift check fails the build on mismatch) — edit the schema in the
astro repo, then sync; never hand-edit the vendored copy here.- Bump
modified:frontmatter on ANY content edit (drives sitemap
lastmod); never rename a slug without a 301. - Ship via the waves-ship skill; pace astro pushes (every push rebuilds the
whole Pages fleet; hub deploy lags 30–45 min). - Quality-gate changes: required publish criteria are HARD (
isHard)
checks, never score weights — recompute the guaranteed floor when adding
checks.
Autonomy posture
- Owner directive 2026-09-05: autonomous supporting blogs require NO human
interaction, including named-competitor and affiliate posts. Clean drafts
publish through the existing checked PR/poller flow. Failed checks retry
within existing budgets or skip with a recorded reason. Do not add a
portal approval queue or email-reply hold to this blog lane. Product
eligibility, facts, content checks, publish caps, and kill switches remain. - Named competitors (owner rulings 2026-09-27 D2 + 2026-09-28): a blog
publishes unattended only when EVERY competitor it names is onOWNER_APPROVED_AUTOPUBLISH_IDSincompetitor-facts.js(Orkin, Terminix,
HomeTeam/TAEXX, Turner, Massey, TruGreen, Aptive, Truly Nolen — the last
two added 2026-09-28). Otherwise it skips asnamed_competitor_off_list; naming anyone else needs a new owner ruling.
Kill switch:GATE_NAMED_COMPETITOR_AUTOPUBLISH=false(skips every
named-competitor blog asnamed_competitor_disabled). - The portal already has a full autonomous content engine
(server/services/content*+ seo services) — audit the gap first, never
rebuild it. Competitor-intercept briefs run fully autonomous; the
Codex-gated auto-merge is the control surface. Blog-backlog PRs merge via
the poller — don't merge them manually. While Codex is usage-limited that
safety layer is ABSENT — treat content auto-merges as ungated during a
sustained limit. - Newsletter voice: irreverent local-guide FOMO, events lead, banned
corporate phrases, sign-off "— The Waves Pest Control Team"; its social
auto-share stays fully automatic (no review queue).
Tooling (recipes in memory / topic files)
- GSC: service-account helper
~/.config/gsc-venv(gsc-query.py,gsc-inspect.py, creds from RailwayGOOGLE_SERVICE_ACCOUNT_JSON), or
gcloud ADC withx-goog-user-project: waves-portal. - Indexing API: recrawl nudge (200 URLs/day), send a browser UA.
- Never automate the GrowthBook UI; provision via its API.
Verification
- Blog: both publish gates pass; rendered URL checked against sitemap;
brand-isolation CI green on spoke-shared edits. - Claims: every stat/claim traceable to the facts bank or a source.
- SEO changes: no protected-family URLs touched without sign-off; sitemap
lastmod updated where content changed.
Fixing a compliance-guard finding (content-guardrails.js)
The guard is a paraphrase classifier, so a finding names ONE spelling of a
class. Close the class, not the sentence in the evidence — patching the named
form is what makes the next review round find the same defect again.
- Widen to the verb/noun family, not the cited word (
finish\w*→finish|complet|done|through). - Check SIBLING branches for symmetry before you call it done: a term added to
the action-verb list usually needs its noun inBANNED_TOPIC_CORE_SRCtoo,
and a separator or qualifier added to one duration branch usually belongs in
the adjacent ones. If neighbours already have the form and yours doesn't,
that asymmetry is the bug. - Ask what PROPERTY the rule encodes, not what string it matches — the
approved-idiom exemption needs "the dry state is the sole condition
governing the claim", whichuntilbroke in one direction and a wet-state
conjunct broke in another. - Assert both directions every time: the positive AND the negative it must not
swallow. A widened matcher that gains a false positive is not a fix. - Run the FULL server suite (
npm test), not just the guardrail file — these
patterns are source-grepped by tests in unrelated suites.
Failure Modes
- "Fixing" intentional SEO (city-page titles, empty spoke blogs, meta
proximity terms). - Fabricated local facts or credentials.
- Promotional social content.
- Hardcoded brand/prices in shared content.
- Unpaced astro pushes hammering the Pages build queue.
Escalation
Ask Adam for: anything touching protected URL families, new spoke
pages/domains, pricing claims, reversing a standing content directive.