Before you run this skill
This skill is brand-neutral. It reads its brand, palette and endpoints from
brand.config.json at the repo root.
On first use, do this before anything else:
- Run
python3 brandkit.py. It prints the config source and any placeholder that is still unset. - If it says
configured: False, copybrand.config.example.jsontobrand.config.json. - Ask the operator for each value under
missing, then write them in. Do not guess a brand name, a domain, or a colour. - Anything the skill writes out should be passed through
brandkit.fill(text), which swaps every{{TOKEN}}for its configured value and remaps the default palette to the operator's.
Text below uses {{TOKEN}} where a value is operator-specific. Treat an
unresolved {{TOKEN}} in your output as a bug, not as literal copy.
Publishing: which CMS
Never write CMS calls by hand in this skill. Use the shared adapter layer, which covers Strapi, WordPress, Contentful, Sanity, Ghost, Webflow, Payload, Directus, and plain files.
from cms import get_adapter, Page, CMSError
cms = get_adapter() # reads cms.kind from brand.config.json
page = Page(slug=slug, title=title, description=meta_desc,
html=body_html, blocks=components, jsonld=graph)
entry_id = cms.upsert(page) # ALWAYS a draft, whatever the CMS
admin, public = cms.locate(page, entry_id)
Rules:
upsert()creates a draft. There is no way to publish through it, by design.cms.publish(page)goes live. Call it only after the operator says so, for this page, in this session. Approval never carries forward.- If
cms.kindisnone,get_adapter()raises. Report that and offerfiles, which writes the page to disk instead, rather than guessing a CMS. python3 -c "import cms; print(cms.describe())"tells the operator what is wired and whether that adapter has been verified live.- Only
strapiandfilesare verified against real instances. For the rest, build the page, run the audit, then tell the operator to expect a possible field-name mismatch on the first push.
{{BRAND_NAME}} Blog Builder (how-to / guide / tutorial)
Ships a {{BRAND_NAME}} how-to blog end-to-end — informational articles, step-by-step guides, tutorials — with mandatory Phase 0 SERP + LLM ranking-factor recon before any content is written. Body ships in Minimal Structure mode (no scoped CSS, no landing-page prefixed classes). Optional real in-app screenshots captured via authenticated Playwright and embedded under each step H3.
Reference implementation: /blog/how-to-create-a-consent-form/ (reference entry). Scratchpad artifacts + captured screenshots at <repo>/scratchpad/{{BRAND_NAME}}-screenshots/ and <repo>/scratchpad/blog_1789_*.html.
Related skills (do NOT overlap with this one):
/listicle— best-of-N comparison blogs (7+ tool tables, G2 ratings, hero shots). Use that instead of/blogwhen the article is a ranked comparison./review-page— competitor review blogs (hands-on 30-day-style review of a single tool). Use that instead when the article is a review.- This skill (
/blog) — how-to guides, informational articles, step-by-step tutorials. Everything else that's a blog but not a listicle or review.
Rule 0 — Inputs (ask one at a time, echo back, confirm)
- Primary keyword — target search phrase (US). Example:
how to create a consent form,how to build a job application form,what is form validation. - Slug — kebab-case URL path under
/blog/. Example:how-to-create-a-consent-form. - Blog title — exact title as shown on page (60-70 chars). Example:
How to Create a Consent Form (5 Steps + Templates). - Blog angle — one of:
step-by-step how-to(numbered steps),informational guide(explainer with subsections),comparison-lite(2-3 options compared, no ranked listicle),checklist / playbook(bulleted actions to run). - In-app screenshots — yes / no. If yes, we log into
{{APP_HOST}}via Playwright and capture one screenshot per step showing the actual {{BRAND_NAME}} UI. Default: yes for step-by-step how-tos, no for informational. - Companion {{BRAND_NAME}} feature to demonstrate — the specific feature the blog builds toward. Example:
consent form with e-signature,AI-generated survey,PDF-to-form workflow. This informs which template we open in the app when capturing screenshots. - Regulated-industry framing (only if applicable) — one of:
explicit-not-hipaa-with-routing— {{BRAND_NAME}} is not HIPAA-certified, route PHI use to Jotform Gold / Cognito Enterprise (recommended for medical / consent / clinical topics)compact-caveat— one-sentence non-HIPAA note onlynot-applicable— topic is not health/consent adjacent, skip HIPAA entirely
After all seven, echo back in one block and wait for explicit confirmation before Phase 0.
Rule 1 — Phase 0 SERP + LLM ranking-factor recon (mandatory, BEFORE any content build)
After Rule 0 collects inputs and BEFORE any other rule fires, run the full Phase 0 SERP + LLM ranking-factor recon per ~/.claude/skills/_shared/serp_llm_recon.md.
Four steps:
- Google SERP top-10 via DataForSEO Live SERP (US, desktop, depth 10). Extract top-10 organic, SERP features (AI Overview, PAA, Video pack, Related Searches, Discussions & Forums), and PAA questions verbatim.
- Firecrawl top 5 commercial competitors (skip academic
.eduunless the topic is IRB / research-vertical). Extract H2/H3 tree, word count, entity coverage, EEAT signals, distinctive sections our default template misses. - LLM citation probe via OpenAI
gpt-4o-search-preview× 4 query variants (informational / how-to / specific-use-case / comparison). Parseannotations[].url_citationfor cited domains + track {{BRAND_NAME}} body mentions. - Synthesize — present the ranking-factor report to the user with: current state, SERP snapshot, competitor pattern, LLM citation status, ranking factors to take, ranking factors to add ({{BRAND_NAME}} differentiators), proposed section plan, meta title + description, HIPAA framing decision.
Wait for explicit user approval of the synthesis before proceeding to the content build. The user can adjust scope, personas, step count, screenshot list, positioning at this stage.
Never skip. Every content skill that shipped without Phase 0 in 2026-07 landed with 0/4 LLM citations and wrong-intent-bucket SERP positioning (documented in the 11-page audit 2026-07-31).
Rule 2 — Minimal Structure blog format (LOCKED)
The single hardest lesson from blog 1789's first version: never ship a blog with scoped <style> blocks or landing-page-style prefixed classes. Blogs use Minimal Structure mode per feedback_blog_minimal_structure_mode.md — semantic HTML plus a small fixed class catalog styled by the site stylesheet.
Required elements every blog ships:
| Element | Pattern |
|---|---|
| Lead paragraph | <p style="font-size: inherit;">Lead sentence.</p> (per feedback_blog_lead_paragraph_inherit.md) |
| Cover image |  markdown at the very top of body, ABOVE the lead paragraph (per feedback_blog_cover_render_pattern.md) |
| Body paragraphs | <p>Body copy.</p> |
| Headings | <h2>, <h3> (NO <h1> — the CMS renders the title) |
| Bulleted lists | <ul><li><strong>Label.</strong> Body.</li></ul> |
| Numbered lists | <ol><li>Step.</li></ol> (or use H3 numbered headings like "Step 1. Title" for main workflow steps) |
| Tables | <div class="table-wrap"><table>…</table></div> (per feedback_blog_body_width_mobile_v3.md — mobile scroll wrap) |
| Callouts / quick answer | <blockquote><p><strong>Quick answer.</strong> …</p></blockquote> |
| Mid-article violet CTA | <div class="{{CSS_PREFIX}}-blog-cta"><p><strong>CTA hook.</strong> Trust line.</p><p><a class="{{CSS_PREFIX}}-blog-cta__btn" href="…">CTA button</a></p><p class="{{CSS_PREFIX}}-blog-cta__meta">Free forever plan · No credit card · Setup in 2 minutes</p></div> (per feedback_blog_tailored_cta.md, ~40-60% depth) |
| FAQ block | <section class="faq"><details><summary>Question?</summary><p>Answer.</p></details>…</section> (per feedback_blog_faq_section_wrapper.md — wrapper is required for accordion CSS) |
| Related reading | <h2>Related reading</h2><ul><li><a><strong>Title</strong></a> - description.</li></ul> (simple list, or the 2-col grid pattern per feedback_blog_related_v2_canonical.md if we want the richer treatment) |
| Internal links | Each internal URL linked AT MOST once (per feedback_no_repeat_internal_links.md). Related-grid at the bottom is the exception. |
| FAQ answers | No external http(s):// URLs (per feedback_no_external_links_in_faqs.md). Internal {{SITE_HOST}} links are fine. |
| No em-dashes | ZERO. Not in body, not in FAQ answers, not in table cells. Use commas, colons, semicolons. |
| No hardcoded dates | Every "as of X" or "updated Y" resolves to today() at build time OR uses "at time of writing" phrasing. |
Explicitly banned in blog bodies (all landed in blog 1789's first version and had to be stripped):
<style>blocks (any number, for any reason){{CSS_PREFIX}}-cmp-*prefixed CSS classes (that's the landing-page convention)<section class="{{CSS_PREFIX}}-cmp-…">wrappers around content blocks!importantdeclarations@importfont URLs- Section-level padding overrides
- Font family declarations (site stylesheet handles this)
Reference: open HTML component/consent-form-generator (page 898) for the LANDING-page-style pattern. Open blog 1789's live body (GET /api/blogs/1789?publicationState=preview → body field) for the BLOG-style pattern this skill produces. They are visually and structurally distinct on purpose.
Rule 3 — In-app Playwright screenshots (optional but recommended for how-tos)
If Rule 0 input 5 = yes, capture one screenshot per numbered step showing the actual {{BRAND_NAME}} UI. This is the most-underrated LLM-citation signal — real product screenshots inside a how-to blog signal first-hand experience, which LLMs and Google both weight.
3a — Login flow (Playwright)
Credentials load from .env at project root: BRAND_APP_EMAIL and BRAND_APP_PASSWORD ({{GOOGLE_ACCOUNT}} account).
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
ctx = browser.new_context(viewport={'width': 1440, 'height': 900})
page = ctx.new_page()
page.goto('{{SIGNUP_URL}}', wait_until='domcontentloaded', timeout=45000)
page.wait_for_timeout(3000)
page.locator('#user_email').fill(EMAIL)
page.locator('#user_password').fill(PASS)
page.locator('input[type="submit"][name="commit"]').click() # exact selector; button[type="submit"] misses because it's <input>
page.wait_for_url('**/forms**', timeout=30000)
page.wait_for_load_state('networkidle')
3b — App URL reference (all discovered on the "Client Consent Form" template, form id 47463, 2026-08-04)
/forms— dashboard (form cards)/forms/create— modal: "How do you want to create a new Form?" with 6 options (Start with AI, Start From Scratch, Use a Template, Import Form, PDF to {{BRAND_NAME}} Form, Backend Form). Ideal for "choose type of form" step./forms/{ID}/builder— Build tab: form editor with left canvas + right Design/Advanced panel. Bottom nav shows page tabs (Welcome / Page 1 / Page 2 / Thank you). Ideal for "write form body" and "add signature field" steps (click Page N to see fields)./forms/{ID}/pdf_editor— PDF Editor tab/forms/{ID}/embed— Embed tab/forms/{ID}/share— Share tab/forms/{ID}/submissions— Results tab (Submissions / Analytics / Summary). Ideal for "audit-ready records" and "timestamp / secure storage" steps — shows search, filter, columns, Export as CSV./forms/{ID}/autoresponder— Automate tab (default sub-tab). Sub-tabs: Autoresponder / Email Notifications / Integrations / AI Workflows / Webhooks. Ideal for "send PDF copy to signer" step — click "Add Autoresponder" to reveal the config modal with Email Body + Email Attachments./forms/{ID}/email_notifications— Email Notifications sub-tab direct- Gear icon in top nav opens a Template Settings MODAL, not a URL. Not useful for standard how-to steps.
3c — Wait for SPA content before screenshotting
The builder is a heavy Vue/React SPA — a bare page.wait_for_timeout(3000) fires before content renders and produces 5-6KB blank white PNGs. Fix:
page.goto(f'{{APP_URL}}/forms/{ID}/builder', wait_until='networkidle', timeout=45000)
page.wait_for_timeout(8000) # extra buffer for SPA hydration
page.screenshot(path=out_path)
Any PNG under 30KB is a load-failure — retry with longer wait or click a specific tab to trigger content render.
3d — Screenshot upload + embed
Upload each to your CMS media library via /api/upload multipart POST (see the CMS adapter in cms/adapters.py for the exact multipart shape).
Embed under each step's H3 heading as:
<p><img src="{cover-url}" alt="{descriptive alt}" loading="lazy" style="width:100%;max-width:900px;height:auto;border-radius:8px;border:1px solid #e5e7eb;margin:16px 0;" /></p>
Position: after the step's H3 + the immediate intro <p>, BEFORE any bullet list of details. This matches the pattern shipped on blog 1789.
Rule 4 — Cover image (mandatory)
Every blog ships with a v6 cover.
- Invoke
/cover-imageskill (or call~/.claude/skills/cover-image/build.pydirectly with the four inputs:--slug,--lines,--orange,--illustration). - Save PNG locally to
<repo>/scratchpad/blog-covers/<slug>.png. - Upload to your CMS media library via
/api/upload→ capture the returnedidandurl. - Set
blog.coverImg = <media_id>andblog.coverImgAlt = "<descriptive alt>"on the blog record. - Prepend
\n\nto body so the in-page hero renders (perfeedback_blog_cover_render_pattern.md). This is the ONE place where markdown mixes with HTML in the body — required, load-bearing.
The cover pattern is the SAME for every content type at {{BRAND_NAME}} (v6, wordmark top-center, illustration left / headline right, deep indigo bg). No blog-specific variant.
Rule 5 — Google Doc draft (approval gate, optional but recommended)
After the body is drafted and screenshots are embedded but BEFORE CMS push, generate a Google Doc for content review.
- Use
/gdocwithGDOC_AUTH_DIR={{GOOGLE_AUTH_DIR}}({{GOOGLE_ACCOUNT}} OAuth perthe Google auth note in the README.md). - Doc lands in "{{BRAND_NAME}} SEO Audits" Drive folder.
- Doc structure follows the shared standard at
~/.claude/skills/_shared/doc_content_standard.md. Metadata table first (Title, Description, Meta title, Meta description, Primary keyword, Primary keyword volume (US), Secondary keywords, Slug, Recommended URL, CMS link) → verbatim body copy with explicit[H1] / [H2] / [H3]heading markers and[Image: ...]placement markers. No commentary, no rationale, no**Why:**lines, no placement notes, no schema blocks, no cover URL or media ids. - When updating an existing page (blog PATCH), regenerate the Doc in place via
--doc-id <existing_id>. - Share URL back to user, wait for content approval before the CMS push PATCH.
Skip if the user says "just push it" — this is a friction-reduction option, not a hard gate. Publish approval on the LIVE blog is still a hard gate (Rule 9).
Rule 6 — CMS push (POST /api/blogs, draft state)
After preflight (Rule 8) passes:
- Payload shape:
payload = {
"data": {
"slug": SLUG,
"title": TITLE, # H1 on page
"description": DESCRIPTION, # 2-3 sentence blurb (blog card + og description)
"metaTitle": META_TITLE, # 30-70 chars, ends with "| {{BRAND_NAME}}" (optional but recommended)
"metaDescription": META_DESCRIPTION, # 120-160 chars
"keywords": ",".join(KEYWORDS), # comma-separated string, NOT a list
"author": "Harish Kumar", # or "Harsh Shah" for personal-voice how-tos
"authorProfile": "https://linkedin.com/in/harshshahseo",
"coverImg": COVER_MEDIA_ID, # int, from Rule 4 upload
"coverImgAlt": COVER_ALT, # string
"featured": False,
"body": BODY_HTML, # ALL Minimal Structure HTML from Rule 2,  prepended
"schema": JSONLD_GRAPH_LIST, # Rule 7 — LIST of {id, type} entries, NOT a nested dict
"publishedAt": None, # HARD RULE — never publish on POST
}
}
bodyfield — Minimal Structure HTML, with the cover markdown image as the FIRST line, then the lead<p style="font-size: inherit;">, then everything else.schemafield on blogs (Strapi only) — it stores this as a LIST of{id, type}entries in a component-repeatable, NOT a single dict. Each entry'stypeis one JSON-LD entity. See the CMS adapter incms/adapters.pyfor the exact shape. The blog page template does NOT render theschemafield to<script type="application/ld+json">today — this is a known Nuxt template bug documented in this session's earlier schema investigation. Ship the schema anyway (the CMS stores it correctly, and any future template fix picks it up).Duplicate slug check first — GET
/api/blogs?filters[slug][$eq]=<slug>&publicationState=preview. If exists, abort with report unless the user explicitly said "PATCH the existing blog."POST — expect 200 with the new blog id.
Verify via GET with
publishedAt: nullconfirmed. Report the CMS admin URL + preview URL:{{SITE_URL}}/preview/?token=<preview_token>&type=blog(your CMS returns the preview token in the response; if not, generate via the CMS admin UI).
Never publish on POST. Rule 9 is the only place publishedAt gets set to a timestamp.
Rule 7 — 6-entity JSON-LD @graph (mandatory even though Nuxt doesn't render it yet)
Every blog's schema list must contain these entities. Nuxt's blog template ignores the schema field today (documented bug), but the CMS stores it and any future template fix picks it up. Do NOT skip.
- Organization — {{BRAND_NAME}}, referenced by
@id: {{SITE_URL}}/#organization - WebPage — url, name, description,
datePublished= today,dateModified= today,inLanguage: en-US, isPartOf → Organization, breadcrumb → BreadcrumbList - BreadcrumbList — Home → Blog → post title
- Article — headline, description,
image= cover URL,datePublished,dateModified,author(Person with url),publisher= Organization,mainEntityOfPage= WebPage - FAQPage — every FAQ from the body's
<section class="faq">block, verbatim as{"@type": "Question", "name": q, "acceptedAnswer": {"@type": "Answer", "text": a}} - SoftwareApplication — {{BRAND_NAME}} product entity: name, url,
applicationCategory: "BusinessApplication",operatingSystem: "Web, iOS, Android",offers(Free $0 + Personal $13),aggregateRating(4.7/5, 11 reviews on G2)
Dates resolve to today at build time. Never a hardcoded string.
Rule 8 — SEO preflight (blocks publish, mandatory)
Auto-run before the publish step. Any FAIL blocks the flip to live.
- Meta title 30-70 chars (blogs allow slightly longer than product pages)
- Meta description 120-160 chars
- Slug matches URL path (
/blog/<slug>/) - Body starts with
markdown image (cover hero) - Body has
<p style="font-size: inherit;">lead paragraph immediately after cover - Zero
<style>blocks in body - Zero
{{CSS_PREFIX}}-cmp-*prefixed classes in body - Zero
<h1>tags in body (title renders as H1) - Zero em-dashes anywhere
- Zero banned placeholders:
TODO,FIXME,INSERT_,REPLACE_,PLACEHOLDER - Every internal URL linked at most once outside the related-grid
- Zero external URLs inside
<section class="faq">answers - FAQ block present with at least 5
<details>items -
<section class="faq">wrapper present around the FAQ block - Every table wrapped in
<div class="table-wrap"> -
blog.coverImgset,blog.coverImgAltset -
blog.schemahas 6 entities per Rule 7 (Organization / WebPage / BreadcrumbList / Article / FAQPage / SoftwareApplication) -
publishedAt: null(still a draft) -
run_preflight()from~/.claude/skills/_shared/preflight.pyon the assembled body — returns clean (0 fixes) -
audit_scan.pyon the assembled body — exit 0
Rule 9 — Publish (only on explicit approval)
Per feedback_never_publish_without_approval.md: never flip publishedAt without explicit user approval on THIS specific blog. Prior approvals do not carry forward.
When approved:
- Set
publishedAttodatetime.now(timezone.utc).replace(microsecond=0).isoformat() + 'Z'. - PUT to
/api/blogs/<id>. - Poll
{{SITE_URL}}/blog/<slug>/for 200. Nuxt static rebuild takes ~5-15 minutes; 404 in that window is expected. - Log to the Cluster Master Tracker
Published Content Trackertab if the source row exists.
Rule 10 — Reference implementation
Canonical reference: /blog/how-to-create-a-consent-form/ (reference entry).
- Body:
GET /api/blogs/1789?publicationState=preview→bodyfield. 18,718 chars, 5 embedded step screenshots, cover markdown image at top,<section class="faq">with 8<details>items, mid-article<div class="{{CSS_PREFIX}}-blog-cta">after the templates section, 1 semantic<table>in<div class="table-wrap">. - Cover: media id 4754, generated via
/cover-imagewith--lines "How to Create a" "Consent Form" "in 5 Steps"and--orange Consent Form 5. - Screenshots: media ids 4749-4753, captured from {{BRAND_NAME}} template form id 47463 ("Client Consent Form") via authenticated Playwright.
When in doubt about a specific pattern (mid-CTA structure, FAQ shape, table wrap, lead paragraph), fetch blog 1789's body and copy the exact HTML.
Rule 11 — Never do
- Never publish without explicit approval on THIS specific blog.
- Never ship a blog with
<style>blocks or{{CSS_PREFIX}}-cmp-*prefixed classes — that's landing-page HTML, not Minimal Structure. Blog 1789's original body was 47KB of scoped CSS; the rewrite dropped it to 17KB of semantic HTML. - Never use em-dashes anywhere in body.
- Never hardcode dates in generated body copy.
- Never invent {{BRAND_NAME}} features. HIPAA / SOC 2 / ISO 27001 are all NO. Never claim "on the roadmap" for any of these.
- Never let a step screenshot fire before the SPA hydrates — 5KB PNGs = load failure. Wait longer.
- Never write the cover image URL to
bodywithout also settingblog.coverImgandblog.coverImgAlt— the cover serves two purposes (og:image + in-page hero) and both need the media set. - Never skip Phase 0 SERP + LLM recon. Every blog that shipped without it in 2026-07 was invisible to LLMs (documented in the 11-page audit 2026-07-31).
- Never use
bodyfield name onmicro-components.raw-htmlcomponents. Blogs don't have components (they use thebodyfield which IS correct on blogs), but if you're building alt-pages or product-pages inside a blog-adjacent flow, the field there ismarkup. - Never let the schema field skip the FAQPage entity. The 6-entity graph is 6 or nothing. Even if Nuxt doesn't render it today, the CMS stores it and Google's Rich Results Test can validate the record.
No self-referential media captions (HARD RULE, added 2026-08-09)
Never add authenticity remarks under or near screenshots, GIFs, or videos: "Real {{BRAND_NAME}} UI, captured in-app", "actual product screenshot", "real UI, not a mockup", "captured from the live app", or any similar meta-commentary about the asset being real. These read as AI-generated filler and were ordered removed site-wide. The asset speaks for itself. If a media element genuinely needs a caption, the caption states only WHAT the user is looking at (e.g. "Form builder, Design tab"), never that it is real, actual, or captured. This applies to every page type: product, landing, alternative, blog, listicle, review, template.
Inspiration pages are design-only, never fact sources (GLOBAL HARD RULE, added 2026-08-10)
Root cause: the Aug 2026 factual audit found 4 alternative pages shipped with the inspiration page's facts (Wufoo's pricing tiers, ownership, founding year) left in place for different competitors. This must never recur.
- An internal inspiration/reference page may be reused ONLY for: page structure, section structure, layout, design patterns, visual treatment, formatting, component patterns, UX approach.
- NEVER carry over content, claims, facts, statistics, pricing, plan names, limits, ownership, founding dates, quotes, or any information from the inspiration page into a new page.
- Research comes FIRST on every new page: (1) research the subject independently, (2) identify current correct information, (3) verify every factual claim against first-party/authoritative sources (official pricing page, docs, company newsroom), (4) write original content from that research.
- Never assume something is correct because it appears on an existing {{BRAND_NAME}} page. Never fill gaps with guesses. If a claim cannot be verified, flag it explicitly instead of presenting it as fact.
- Cloned template blocks that carry competitor-specific facts (pricing tables, "why teams leave" cards, honest-comparison cards, free-plan tables) must be rebuilt from fresh research for every new subject, not find-and-replaced.