// One-time onboarding for a new consumer site: SEO audit of the existing site, Next.js 16 scaffold, @m13v/seo-components install, route-group architecture, real images/video/structured data, Cloud Run deploy, dashboard registration, and SEO infrastructure wiring (withSeoContent, guide index, optional sidebar and AI chat). This skill stops at infrastructure; day-to-day SEO guide page generation is handled by the gsc-seo-page skill and seo/generate_page.py. Use when: 'set up client website', 'onboard new client site', 'new consumer site', 'recreate website', 'rebuild website', or when spinning up a fresh site that will later receive programmatic SEO pages.
One-time onboarding for a new consumer site: SEO audit of the existing site, Next.js 16 scaffold, @m13v/seo-components install, route-group architecture, real images/video/structured data, Cloud Run deploy, dashboard registration, and SEO infrastructure wiring (withSeoContent, guide index, optional sidebar and AI chat). This skill stops at infrastructure; day-to-day SEO guide page generation is handled by the gsc-seo-page skill and seo/generate_page.py. Use when: 'set up client website', 'onboard new client site', 'new consumer site', 'recreate website', 'rebuild website', or when spinning up a fresh site that will later receive programmatic SEO pages.
user_invocable
true
Setup Client Website
One-time onboarding flow for a new (or rebuilt) client/consumer site. Produces a modern, SEO-optimized Next.js site with real content, images, video embeds, structured data, and the infrastructure needed for programmatic SEO guide pages.
Scope boundary: this skill leaves the repo in a state where /t/<slug> guide pages can be produced. It does NOT write guide pages. All guide-page content generation is handled by the gsc-seo-page skill, which drives ~/social-autoposter/seo/generate_page.py. After running this skill, register the site in ~/social-autoposter/config.json and hand off to gsc-seo-page for every page.
Warm-start handoff: when the client has an existing blog, Phase 1c.1 produces research/client-blog-inventory.json, a seed list of unbranded category queries derived from the client's existing posts. Phase 9 upserts it into seo_keywords with source = 'client_blog' before the cron activates, so the first 20 to 50 guide pages target topics the client has already invested editorial work into rather than cold DataForSEO discovery. The inventory captures topics and traffic signals only, never body copy: every /t/<slug> page is still a substantially longer, unbranded rewrite written by gsc-seo-page.
Business model: we own the funnel end-to-end
We build a separate SEO site alongside the client's brand site because we own this new domain, the traffic it earns, and the conversion path that ends at our booking link (or our tracked get-started URL). The client keeps their brand site; we route organic visitors through a tracked CTA on a domain we control until the booking fires. That is the whole business model: we are paid for bookings, not for traffic, so every visitor who leaves to the client's brand or product domain before the CTA fires is revenue handed back for free.
Hard rule: no marketing CTA ever points at the client's brand or product domain. Not <brand_domain>, not hire.<brand_domain>, not app.<brand_domain>, nowhere in src/app/(main)/. Privacy and terms should be written as self-contained pages on the new domain, not as stub pages that link out to the brand site's legal copy. Every tier button, hero CTA, pricing card, inline CTA, modal CTA, and footer CTA terminates on our site: /precall, BookCallLink, GetStartedLink, a modal that captures email then routes to a tracked booking, or a tracked trackAs="..." CTA. A raw <a href="https://<anything-the-client-owns>"> on a marketing page is a bug, same severity as a raw <a href="https://cal.com/..."> that bypasses withBookingAttribution. Phase 8 must grep src/app/(main)/ for anchors pointing at brand_domain (or any of its subdomains) and fail the audit on any hit.
Self-serve CTAs on a book-a-call-only site. When the research brief surfaces a self-serve product motion (pricing page, "Start free" tier, download button) but only book-a-call scope is enabled, do NOT link the self-serve CTA at the client's product app. Build an email-capture modal on our domain that collects the email, sends a welcome transactional via Resend containing the Book-a-Call link, and in the modal itself routes the user to /precall or BookCallLink. The client still gets the signup, we keep the funnel.
Hard rule, copy edition (gated-redirect on): the destination URL never appears in user-visible text either. The anchor rule above closes the link surface (no <a href> to the brand). When gated-redirect is on, the destination URL must also be absent from: body copy in any .tsx under src/, FAQ Q&A objects, footer paragraphs, modal subtitles, metadata.description, OpenGraph descriptions, JSON-LD sameAs arrays in layout.tsx, alt text, and the welcome-email HTML template inside src/app/api/signup/route.ts. The bare brand name (Jungle, Piastech) may stay where it's needed for legal/entity disclosure (privacy and terms keep <Brand> Inc. as the operating entity), but the literal URL string (app.<brand>.com, <brand>.com, even fragments like <brand>.com/terms-of-service) does not. The welcome email body sent by Resend carries the access link in an <a href={redirect}>, which is unavoidable and fine; that email is post-gate, not pre-gate. Phase 8 audits this by grepping the whole src/ tree (not just src/app/(main)/) for the brand and app URL stems and failing on any match outside the welcome-email payload. A user-visible "Where am I being redirected?" FAQ Q&A that names the destination is the most common source of leak; do not generate one.
Arguments
Provide the client name, domain (if any), and existing site URL (if any). Example: "Paperback Expert at paperbackexpert.com"
Optional scope flags
These are OFF by default. Only enable them if the invoker mentions the feature explicitly (e.g. "with book-a-call", "with get-started", "add a contact form"). If the scope flag is not mentioned, skip every phase marked [opt-in: book-a-call] / [opt-in: get-started] — do not half-scaffold placeholders.
3.5d″ EmailGateModal + /api/signup scaffold (with mandatory Resend audience upsert + newsletter_subscribed event), server-only GET_STARTED_URL, runtime envs RESEND_API_KEY+RESEND_AUDIENCE_ID on the deploy target (NOT just .env.local), Phase 8 destination-leak audit + audience-population audit + Email-Signups dashboard audit. Requires get-started to also be on.
Get-Started covers every self-serve primary CTA: downloads (Mac .dmg, App Store, /download), installs (/install, Chrome Web Store, browser extension listings), and signups (SaaS app.<domain> entry, waitlist, trial start). They all fire the same get_started_click PostHog event and roll into one "Get Started" column in the dashboard. Aliases like download-link or signup-link map to this flag.
Always-on (NOT behind a flag): inbound email. Every client site sends from matt@<domain> (per defaults.sender_local_part in ~/social-autoposter/config.json). Because that's a real human-named address, recipients WILL hit Reply. Every site MUST therefore wire Resend Inbound (Phase 3.5k) so replies route to defaults.inbound_forward_email (i@m13v.com by default). This is not optional regardless of which feature flags are on. Studyly 2026-05-05 shipped without inbound and we lost replies from 6 captured signups before noticing.
When a flag is off: do not add the corresponding CTA components, do not add the corresponding field to config.json, and skip the related Phase 8 checklist rows.
Book-a-call off → no <BookCallLink>/<BookCallTracker>, no Cal.com event type, no booking_link in config.json.
Get-started off → no <GetStartedLink>/<GetStartedCTA>, no get_started_link in config.json. The stats pipeline counts get_started_click per-host, so a site that does not fire this event will simply show 0 — harmless.
Both flags can be on for the same site. Fazm is the canonical example: it has a booking_link (Cal.com team URL for pilot calls) and a get_started_link (Mac app download). Assrt is another: booking_link for the pilot call plus get_started_link pointing at app.assrt.ai for self-serve signup.
Gated-redirect: when to turn it on. The default get-started scaffold renders <GetStartedLink href={GET_STARTED_URL} target="_blank">, which puts the destination URL in the DOM (href attribute, hover status bar, "copy link"). That is fine when the destination IS our domain (Fazm: fazm.cc/download) or when we don't mind users bypassing email capture (Assrt: app.assrt.ai with no separate brand domain). Turn gated-redirect ON whenever the get-started destination is on a different domain we want to keep hidden until the visitor submits an email, typically when brand_domain ≠ website AND the brand_domain is the self-serve product app (e.g. studyly.io routes to app.jungleai.com). With this flag on, every Get-Started CTA opens an email-gate modal; /api/signup collects the email, sends a Resend welcome with the access link, and returns the redirect URL via JSON for window.location.href. The destination URL never appears in HTML, body copy, FAQ, JSON-LD, or metadata.description. Studyly is the canonical example.
Rationale: free OSS tools and install-driven products (ClaudeMeter, appmaker-style utilities) have no "book a call" conversion — they need get-started instead. Enterprise pilot sites with no self-serve path need only book-a-call. Product-led sites with both a pilot offer and a self-serve entry point need both. Forcing either wiring on a site that does not match the conversion shape produces dead links and skewed funnel stats.
Prerequisites
Google Cloud project under the org specified in ~/social-autoposter/config.json > defaults.gcp_organization_name (see 1.5e — per-client projects are created as <slug>-prod)
GitHub org or personal account
PostHog account (org specified in config.json > defaults.posthog_org) for analytics
Resend account (you@example.com) for transactional email
Neon account for Postgres (one project per client, pooled connection)
Google Search Console access
Isolated browser MCP for visual comparison
Stack
Next.js 16 (App Router) + React 19 + TypeScript
Tailwind CSS 4 (inline theme via @theme)
next/image for optimized images
Google Cloud Run for hosting (with HTTPS Load Balancer + Certificate Manager)
PostHog for analytics (pageviews, CTA clicks, newsletter subscribes)
Neon (@neondatabase/serverless) for email + contact logs
Phase 1: Audit and Research
Phase 1 runs two tracks concurrently:
Outward track (1a): understand the market the client sells into (competitors, search demand, industry developments, ICP).
Inward track (1b-1e): audit what the client already has (SEO baseline, content crawl, assets, screenshots).
Both tracks feed 1f, which produces a single research-brief.md that every downstream phase (copy, hero, CTAs, FAQ, case studies) is required to consume. Research that does not make it into the brief is decoration.
1a. Market Research Fan-Out
Launch these agents in parallel. They have no dependencies on each other or on the inward track, so batch them in a single message.
Launch 4 agents in parallel:
- competitor-analysis: identify top 3-5 rivals by SERP + brand search.
Per rival, capture: positioning one-liner, pricing, primary CTAs,
hero copy, testimonial themes, messaging pillars, obvious gaps or
weaknesses.
- keyword-research + serp-analysis (single agent, both skills): head
terms, long-tail clusters, search intent (informational /
commercial / transactional), SERP feature mix (AI Overviews, PAA,
video, local pack), difficulty, monthly volume.
- deep-research-pro: industry developments in the last 90 days,
regulation, notable launches, funding, M&A, price moves, new
entrants, platform / distribution shifts. Cite sources.
- general-purpose (ICP pass, with WebFetch): 1-2 primary personas with
jobs-to-be-done, top 3 pains, top 3 gains, objections, triggers,
and the language they actually use (verbatim pulls from Reddit
threads, review sites, forum posts, NOT marketing copy).
Output: four raw reports in research/raw/ (competitors.md, keywords.md, industry.md, icp.md). Do not edit them down here, 1f does the compression.
Budget guardrail: if any single agent returns more than ~15k tokens, ask it to re-emit a tighter version capped at ~8k before moving on. Raw-output bloat is the main failure mode of this step.
1b. SEO Audit (if existing site)
Run parallel SEO agents to baseline the current site. This runs concurrently with 1a.
Launch 5 agents in parallel:
- seo-technical: crawlability, indexability, Core Web Vitals, mobile
- seo-content: E-E-A-T signals, readability, content depth
- seo-schema: existing structured data (JSON-LD, Microdata, RDFa)
- seo-performance: Lighthouse scores, LCP, CLS, TBT (desktop + mobile)
- seo-geo: AI crawler accessibility, llms.txt, citation readiness
Record all scores. These become the "before" baseline and the fix list for the new site.
1c. Crawl All Pages
Use an agent with WebFetch to discover and extract content from every page on the site:
Fetch the homepage, extract all navigation and footer links
Full body text (quotes, testimonials, stats, descriptions)
CTA text and link targets
Form fields (if any)
Navigation links (to discover more pages)
Output: Complete content inventory organized by page.
1c.1. Client Blog Inventory (warm-start seed for gsc-seo-page)
Skip this sub-phase only if the client has no blog, resources, guides, learn area, or article archive. When a post corpus exists, the upfront cost is one pass now versus a cold start for the SEO pipeline.
Goal: turn the client's existing blog into a seed list that Phase 9 upserts into the seo_keywords table on day one. The first 20 to 50 guide pages on our domain then target topics the client has already invested editorial work into, instead of cold DataForSEO discovery.
Hard boundary: the inventory captures topics, queries, and traffic signals, never body copy. Our /t/<slug> pages must be substantially longer, unbranded-intent rewrites of the same topic. Pasting their post body would trigger duplicate-content filtering and push our pages behind theirs in the SERP, defeating the entire reason we run a separate SEO domain (re-read line 15 of this file).
1c.1.a. Discover blog URLs
Extend the 1c crawl to follow paths matching /blog, /resources, /guides, /learn, /posts, /articles, /insights, /academy, /journal, plus any /[category]/[post-slug] pattern observed in the navigation. If the client has a sitemap, parse sitemap.xml and any post-sitemap.xml first; that is usually the complete list. Walk category and tag indexes to catch posts not in the sitemap.
1c.1.b. For each post, capture
Field
Source
url
canonical URL from <link rel="canonical"> or final URL after redirects
title
page <title> (often "Post Title | Brand"); strip the brand suffix
h1
first <h1> text
h2s
ordered list of <h2> strings (reveals sub-topics worth splitting into separate /t/<slug> pages)
publish_date
<meta property="article:published_time">, visible byline date, or sitemap lastmod
word_count
rough body word count (strip nav, sidebar, footer)
primary_query
inferred unbranded category query, 2 to 5 words (see 1c.1.c)
secondary_queries
up to 3 adjacent queries each large enough to be its own /t/<slug> page
suggested_slug
kebab-case of primary_query
gsc_impressions
trailing 90-day impressions if we have GSC access (1c.1.d), else null
gsc_position
trailing 90-day average position if available, else null
dedupe_note
"", or "near-dup of <other url>" when the client has two posts on the same query
skip
false by default; "brand-only" if the post cannot be rewritten for unbranded intent (see 1c.1.c step 4)
1c.1.c. Deriving primary_query
The client's title is almost never the right query for our domain. It is usually brand-forward ("How Cyrano Helps Apartment Owners Deter Package Theft") and we need unbranded category intent ("how to prevent apartment package theft"). For each post:
Strip the brand name and product name from the title.
Rewrite as the phrase a buyer who has never heard of the client would Google.
Validate informally with Google Autocomplete; an autocompletion suggests the query has demand.
If the rewrite forces you into a branded or tooling-specific phrase ("Cyrano integrations", "Fazm pricing"), mark the row skip: brand-only and exclude it from the seed list. Brand queries belong on the brand site, not on our SEO domain.
1c.1.d. GSC join (optional but high-value)
If the client will grant Search Console access, have them add the email defined in ~/social-autoposter/.env as GSC_ADMIN_EMAIL (the address that owns the GCP project running this pipeline) as a Restricted user on their GSC property before this sub-phase runs. With access, fetch trailing 90-day query data per post URL through the GSC API (searchanalytics.query with dimensionFilterGroups on page) and join it onto the inventory. Priority tiers (used as a soft sort in the output .md, no special handling in the seed insert):
Tier 1 (highest priority): posts ranking positions 5 to 25 on a non-branded query. Proven demand, good on-domain authority, reachable with a better page on our domain.
Tier 2: posts ranking positions 26 to 100. Topical demand exists but the post under-delivers; our rewrite has plenty of room above it.
Tier 3 (deprioritize): posts ranking positions 1 to 4 on their own domain. The client already owns that query; our version would cannibalize before it overtakes. Seed only if the secondary queries on that post are unowned.
When GSC access is not granted, all rows go in as Tier 2 by default; volume scoring happens later through DataForSEO via seo_keywords.volume.
1c.1.e. Output two artifacts
Write a human-readable Markdown table at research/client-blog-inventory.md (the user skims and vetoes rows by setting skip: <reason>) and a machine-readable sidecar at research/client-blog-inventory.json (array of row objects in the schema above). The Phase 9 seed step reads the JSON file directly. Suggested .md columns: url, primary_query, tier, gsc_impressions, suggested_slug, skip.
1c.1.f. Seeding happens in Phase 9, not now
Seeding into seo_keywords cannot run until the product row exists in ~/social-autoposter/config.json (Phase 10a) and the GSC pipeline credentials resolve (Phase 9 prerequisites). Phase 9 step 6 runs the upsert and uses:
INSERT INTO seo_keywords (product, keyword, slug, source, status)
VALUES (%s, %s, %s, 'client_blog', 'unscored')
ON CONFLICT (product, keyword) DO NOTHING
source = 'client_blog' gives traceability in the dashboard (warm-start versus cold-discovery) without special-casing the scoring path. status = 'unscored' lets the existing db_helpers.pick_next_keyword flow score, rank, and promote the seeds the same way it handles every other inbound keyword.
1d. Extract Visual Assets (and Brand Identity)
Use the isolated browser to catalog images, videos, and embeds on the original brand domain (e.g. piastech.com, cyrano.ai), not on the new generic SEO domain. The brand identity is the property of the brand domain.
// In isolated browser, navigate to each page and run:
() => {
const imgs = Array.from(document.querySelectorAll('img')).map((img, i) => {
const rect = img.getBoundingClientRect();
return { idx: i, src: img.src, y: Math.round(rect.y), w: Math.round(rect.width), h: Math.round(rect.height) };
}).filter(x => x.w > 50);
const iframes = Array.from(document.querySelectorAll('iframe')).map(f => f.src);
return { imgs, iframes };
}
Key assets to identify and download:
Brand wordmark / logo (header top-left; may be SVG, PNG, or inline text — capture exact spelling, casing, and any logomark character like π(a|s))
Favicon (/favicon.ico, /favicon.png, or any <link rel="icon"> href)
Hero images or background photos
Client/team headshot photos (circular, 100-200px)
Product images (book covers, screenshots, etc.)
Social proof imagery (awards, certifications, partner logos)
Video embeds (Vimeo, YouTube URLs)
Scheduling widgets (Calendly, Cal.com URLs)
Book cover strips / product galleries
Brand colors and fonts. Dump the brand site's CSS custom properties and fonts so the new site can match the palette:
// Run on the brand domain's homepage:
() => {
const r = getComputedStyle(document.documentElement);
const b = getComputedStyle(document.body);
const varNames = ['--background','--foreground','--primary','--primary-foreground','--accent','--card','--secondary','--muted','--border'];
const vars = Object.fromEntries(varNames.map(n => [n, r.getPropertyValue(n).trim()]));
return {
bodyBg: b.backgroundColor,
bodyFg: b.color,
bodyFont: b.fontFamily,
vars,
};
}
Also pull the brand site's CSS file and grep for hex/hsl color values if CSS vars aren't exposed:
Save the palette to research/brand-identity.md with: brand name exact spelling, logomark character (if any), primary/accent hex codes, body/heading fonts, favicon URL, and the logo image URL.
Download all identified images to public/images/ with descriptive filenames. Copy the favicon to public/favicon.png and convert to src/app/favicon.ico (multi-size .ico) with:
Capture full-page screenshots of every key page on the original site for visual reference:
For each page:
1. browser_navigate to URL
2. browser_take_screenshot with fullPage: true
3. Save as original-{pagename}-full.png
1f. Synthesize Research Brief
Once 1a-1e are all complete, run a single synthesizer pass (general-purpose agent, no fan-out) that reads every file in research/raw/ plus the crawl inventory and produces research/research-brief.md using exactly this schema:
# Research Brief: <client>## Positioning angle (one sentence)<singlecrispsentence — whattheclientuniquelyis, forwhom, againstwhom>## 3 differentiators1.<differentiator> — proof: <citation / source / datapoint>2. ...
3. ...
## 5 messaging pillars1.<pillarheadline> — supporting evidence: <...>
2. ...
...
## ICP (1-2 personas)
For each persona:
- Name + one-line description
- Top 3 jobs-to-be-done
- Top 3 pains (verbatim language from research)
- Top 3 gains
- Primary objection + the counter
- Trigger event that starts the buying journey
## Proof points
Verifiable stats, awards, case-study numbers, named clients, certifications, press mentions. Anything that can appear on the site as evidence — with source.
## Competitor landscape (one paragraph)
Who the client is up against, how they're positioned, and the gap the client is walking into.
**Direct competitor root domains (5 to 7):** plain bullet list of bare domains, e.g. `scribemedia.com`, `forbesbooks.com`. These are lifted verbatim into the `competitor_domains` field of `config.json` in Phase 10a and drive DataForSEO `keywords_for_site` discovery (`seo/generate_keywords.py`). Drop marketplaces (reedsy, upwork, fiverr) and personal-brand pages; keep direct category competitors only.
## Banned clichés
Phrases pulled from competitor copy that the new site must NOT reuse (e.g. "cutting-edge", "world-class", "one-stop-shop", plus any industry-specific filler found during 1a).
## Industry signals (last 90 days)
3-5 bullets. Only items that should change hero copy, FAQ, or CTAs. Everything else is cut.
Hard rules for the synthesizer:
Every claim must trace to a file in research/raw/ or the crawl inventory. No invented stats.
Hero copy, CTAs, FAQ answers, and case-study selection in Phases 3+ must cite lines from this brief. If a later phase wants to say something not in the brief, it returns to 1a instead of making it up.
The brief is the artifact. The raw reports in research/raw/ are scratch and can be deleted after 1f lands.
1g. Visual Reference Brief (required for Phase 3)
Phase 3 no longer prescribes how each section looks. It prescribes structure and tokens; visual treatment comes from this brief. Without research/visual-references.md, Phase 3 cannot start.
Pick 3-5 reference sites:
2 direct category competitors (the best-designed ones from Phase 1a, not the most prominent).
2-3 design benchmarks from adjacent categories. Rotate so no two client sites share the same benchmark set. Pool: Linear, Raycast, Anthropic, PostHog, Stripe, Resend, Rauno, Framer, Arc, Readwise, Granola, NYT Cooking, magazine editorial layouts (Apple newsroom, Stripe Press), brutalist/Swiss sites.
For each reference, capture in research/visual-references.md:
Screenshot at research/visual-references/<slug>.png (full page via isolated browser).
Palette (3-5 hex codes; note which is background, ink, accent, signal).
Type stack (display, body, mono families and weights).
2-3 distinctive motifs (e.g. section numbering with mono chip, live animated product mock in hero, paper grain overlay, hairline grid instead of cards, pull quotes with giant serif marks, floating persistent chip, scroll-reveal stagger, italic serif for emphasis, tilt-on-hover, copy-to-clipboard CTA, kinetic type).
One-line observation: what this reference does well that this client can borrow.
Close with a design thesis (one paragraph): the visual identity for this client site. Must be specific enough that two agents reading it would produce the same palette family and the same 2-3 signature motifs. Example: "Editorial, Swiss, on paper-cream #F4EEE4 with ink #121110 and signal orange #E8471C. Serif display (Instrument Serif, italic for emphasis), sans body (Geist), mono for meta and numbers (Geist Mono). Signature motifs: numbered section eyebrows with hairline rules, live-ticking product mock as the hero anchor, copy-to-clipboard install chip as primary CTA, pull quotes with 90px orange serif mark, floating persistent product chip that follows scroll."
Exemplar to read first:~/.claude/skills/setup-client-website/exemplars/claude-meter-editorial.html. This is ONE treatment (editorial, Swiss, paper-and-ink, product-first). Do not copy it. Read it as proof that Phase 3 output can be distinctive, product-first, and tactile, rather than the corporate baseline in Appendix A. Add a second and third exemplar to exemplars/ over time so this skill accumulates visual range.
Phase 1.5: Generic Domain Discovery and Purchase
Goal: secure a generic, keyword-rich domain that describes the product category rather than the brand (pattern: Cyrano → apartment-security-cameras.com). The brand domain (e.g. cyrano.ai) still exists; the generic domain is what the new Next.js site ships under for SEO.
Skip this phase only if the user explicitly opts out or the brand domain IS already the generic descriptive domain.
1.5a. Brainstorm candidates
Launch one subagent to generate two tracks of candidates, 15-25 per track. Both tracks describe the product category and never include the brand name.
Track A: Short brandable names (priority). These are the preferred output.
Length cap: under 8 characters for the stem (TLD excluded). Total domain with TLD should stay ≤ 11 chars where possible. Examples: s4l.ai (stem 3, total 6), fazm.ai (stem 4, total 7), mk0r.com (stem 4, total 8), fde10x.com (stem 6, total 10). Shorter stems are better.
Derive from the product category keyword by vowel drops, consonant clusters, or playful contractions (e.g. camera → cam, cmra, kmr; security → sec, scrty).
Digit substitution for availability. Swap letters for visually or phonetically similar digits to find available domains: o ↔ 0, i/l ↔ 1, e ↔ 3, a ↔ 4, s ↔ 5, b ↔ 6 or 8, t ↔ 7, g/q ↔ 9. Pattern matches the user's own brands: s4l.ai, mk0r.com, cl0ne.ai, t8r.tech, fde10x.com.
TLDs ordered by preference: .com (strongly preferred, always default), .io, .co, short-country TLDs like .bz, .me, .to. Do NOT suggest .ai domains..ai is not supported by Cloud Domains (our only registrar), carries a mandatory 2-year registration via third-party registrars, and runs ~$75-$100/yr. Skip it entirely unless the user explicitly asks for .ai.
Must still phonetically suggest the category (not random letter soup).
Track B: Keyword-rich descriptive names (fallback). Use when Track A returns nothing available under the price cap.
3-6 words max; hyphens allowed.
Keyword-front-loaded (e.g. apartment-security-cameras.com, not best.cameras.for.apartments.com).
Mix TLDs: .com (strongly preferred, always default), .io, plus any niche Cloud Domains-supported TLD that fits the category (e.g. .cameras, .tech, .studio). Do NOT include .ai (see Track A TLD rule).
Seed the subagent with the Phase 1 research brief (research/brief.md) and the product's top 3 SEO keywords.
Output: research/domain-candidates.md with Track A on top, Track B below. One domain per line, grouped by track.
1.5b. Bulk discovery via Cloud Domains search
Run Google Cloud Domains' keyword search against the top 3 SEO keywords from the research brief. This adds Google's own suggestions on top of the subagent list:
This returns live availability and price (unlike search-domains which is cached). Parse availability (must be AVAILABLE) and yearlyPrice.units + yearlyPrice.currencyCode.
Run candidates in parallel with xargs -P 5 or a bash loop. Build a table of available domains and first-year price. Filter:
Drop any domain where availability != AVAILABLE.
Default price cap: $50/yr. Anything above goes in a separate "premium" list.
If get-register-parameters returns TLD_NOT_SUPPORTED for a candidate, drop the candidate and suggest an alternative TLD. Cloud Domains is the only registrar; we do not maintain a fallback path.
1.5d. Present top picks to the user
Show the user a ranked short-list (5-10 entries) with columns: domain, length (chars incl. TLD), price/yr, tld, track (A=short, B=keyword-rich), rationale. All candidates must be Cloud Domains-supported TLDs.
Ranking rules:
Track A candidates under 8 characters rank above Track B.
Within Track A, ties broken by: .com > .io > .co > others. .ai is excluded.
Within Track B, .com beats everything else; ties broken by shorter length.
Always include at least one .com if any .com is available across either track.
Wait for the user to pick one. Never pre-select. Never auto-buy.
1.5e. Purchase via Cloud Domains
Preconditions:
Contact YAML (one-time setup per user, reusable across purchases). If ~/.config/gcloud/domain-contacts.yaml does not exist, create it with the user's WHOIS contact info and chmod 600 it. Template:
allContacts:email:you@example.comphoneNumber:'+1.XXXXXXXXXX'postalAddress:regionCode:USpostalCode:'XXXXX'administrativeArea:CAlocality:SanFranciscoaddressLines: ['123 Example St']
recipients: ['Your Name']
Ask the user for the phone/address the first time; reuse thereafter.
GCP project: create one per client site. Naming convention: from config.json > defaults.gcp_project_naming_convention (currently <slug>-prod, e.g. fde10x-prod, cyrano-prod). Every field below is read from config.json > defaults; the skill never hardcodes these values.
CFG=~/social-autoposter/config.json
ORG_ID=$(jq -r '.defaults.gcp_organization_id'"$CFG")
BILLING=$(jq -r '.defaults.gcp_billing_account'"$CFG")
AUTH_ACCOUNT=$(jq -r '.defaults.gcp_auth_account'"$CFG")
PROJECT="<slug>-prod"
gcloud config set account "$AUTH_ACCOUNT"# billing org membership requires the right identity
gcloud projects create "$PROJECT" --name="$PROJECT" --organization="$ORG_ID"
gcloud billing projects link"$PROJECT" --billing-account="$BILLING"
gcloud config set project "$PROJECT"
gcloud config get-value project # must print "$PROJECT"
Prefer Workload Identity Federation for CI/CD over service account keys on any new project; some org policies block SA key creation outright.
Cloud DNS zone created inside the same per-client project. Zone name follows the domain stem:
gcloud dns managed-zones create <STEM>-zone --dns-name="<domain>." --project=<slug>-prod
Pick the right --contact-privacy value per TLD. Do NOT hardcode private-contact-data.
.com (and other Squarespace-reseller TLDs): use redacted-contact-data. Since Google Domains was spun off to Squarespace, new .com registrations reject PRIVATE_CONTACT_DATA with "does not support contact privacy type PRIVATE_CONTACT_DATA."
.ai, .io, and many others: use private-contact-data.
When in doubt, run --validate-only first with private-contact-data. If it errors, parse the allowed values from contactSettings.privacy in the error output and retry with the correct flag.
Dry-run / preview first with --validate-only:
gcloud domains registrations register <domain> \
--project=<slug>-prod \
--contact-data-from-file=~/.config/gcloud/domain-contacts.yaml \
--contact-privacy=<redacted-contact-data OR private-contact-data per rule above> \
--yearly-price="<price from 1.5c>" \
--cloud-dns-zone=<STEM>-zone \
--validate-only
This returns the exact price + any required --notices (some TLDs require HSTS preload or similar).
Pause and ask the user to confirm. Print literally: About to register <domain> for $<price>/yr (renews at same). GCP project: <slug>-prod. Confirm? Wait for an explicit yes.
On yes, re-run the same command WITHOUT --validate-only and with --quiet (since contact/price/DNS are already supplied). Add --notices=<notices> if step 2 flagged any.
Verify: gcloud domains registrations list --project=<slug>-prod should show the new registration in state ACTIVE (may take up to 5 minutes; REGISTRATION_PENDING is normal during that window).
Domain purchases are irreversible. Per the global Ethics Check rule, step 3 is mandatory.
1.5f. Record in config.json
Add the project entry to ~/social-autoposter/config.json. The full shape is owned by the downstream writer (with weight, description, platform, topics, twitter_topics, linkedin_topics, landing_pages, voice, icp, features, differentiator, etc.). The fields Phase 1.5 is responsible for:
{"name":"<stem>","website":"https://<generic-domain>","brand_domain":"https://<brand-domain>","weight":10,
...rest owned by downstream
}
website is the generic domain (the one the new site deploys to). brand_domain is the original brand URL (kept for reference, may redirect later).
weight default: 10 (matches existing top-tier projects: fazm, mediar, assrt). Lower it only if the user explicitly asks the new site to be deprioritized vs existing projects. weight controls how often pickers (pick_project.py, select_product.py, pick_thread_target.py, pick_top_pages.py) sample this project for engagement and content runs; setting it lower than peers silently buries the new site.
Add one field: "gcp_project": "<slug>-prod" so downstream scripts know where the Cloud Run service and DNS zone live. registrar and dns_zone are derivable (always Cloud Domains, always <STEM>-zone).
1.5g. Hand off to Phase 2
Phase 2 scaffolds under ~/<stem>-website/ (derive the slug from the purchased domain's stem, e.g. fde10x.com → ~/fde10x-website/). Phase 5 deploy and Phase 6 DNS wire-up use the generic domain as the primary target.
1.5h. Brand identity persists (the generic domain is SEO-only)
Hard rule: the generic domain is a URL, not a rebrand. When Phase 1.5 buys a keyword-rich domain (e.g. fde10x.com, apartment-security-cameras.com), that domain exists purely to rank on SEO keywords. The client's original brand is untouched and must be carried into the new site.
Carries over from the brand domain
Stays as the generic domain
Brand name (exact spelling, casing, any logomark character)
metadataBase, OpenGraph url, canonical URLs
Brand logo + favicon
Support email addresses (hello@<generic>.com)
Primary + accent colors
DB table names, internal slugs, PostHog site ID, env var names
Heading + body font pairing
GSC property, robots.txt, sitemap URL
Tagline / positioning (from the brand site hero)
Cloud Run service name, GCP project slug
Phase 3 (copy + design system), Phase 3a (Header), Phase 3b (Footer), Phase 3c (metadata + JSON-LD), and Phase 3.5 (email copy, PostHog name) must render the brand name from research/brand-identity.md, not the generic-domain stem. Email and URL strings keep the generic domain.
If the brand name and generic-domain stem happen to be the same (e.g. a client whose brand is literally FDE10x), this rule is a no-op. In every other case, use the brand name from research/brand-identity.md for every user-facing string and reserve the generic domain for technical identifiers.
Why the floor of 0.20.1: the /api/guide-chat route handler gained a healthCheck branch in v0.20.1. Bundles older than that respond to the GuideChatPanel's boot-time health probe with 400 no_messages, which the client reads as res.ok === false and hides the panel silently. Sites stuck on a pre-0.20.1 bundle look "fine" but never render the page assistant.
2c. Configure Theme (globals.css)
The theme uses CSS custom properties in :root mapped into Tailwind 4 via @theme inline. Always define both the CSS variable AND the Tailwind mapping. Every client gets a primary color, a dark variant, and an accent color at minimum.
Source the values from research/brand-identity.md (written in Phase 1d). Do not pick arbitrary colors. If the brand site uses hsl(217 91% 50%) as its primary, use #3b82f6 here. If the brand site has no extractable palette, default to a neutral navy-and-white scheme and flag it for the user to confirm.
/* REQUIRED: pre-register cascade layers so @m13v/seo-components (>=0.14.1)
library CSS lands in @layer seo-components (lowest priority) and can't
beat consumer Tailwind utilities. See feedback_seo_components_layer_order
memory. */@layer seo-components, theme, base, components, utilities;
@import"tailwindcss";
/* REQUIRED for Tailwind v4: scan the @seo/components source so utilities
used inside library components (bg-teal-500, rounded-lg, px-3,
border-zinc-200, etc.) land in the HIGH-priority `utilities` layer of
the consumer's CSS. Without this, the packaged fallback CSS sits in the
low-priority `seo-components` layer and gets overridden by Tailwind's
`base` preflight — Subscribe button goes transparent, input border
goes black, horizontal padding collapses to 0. Claude-Meter shipped
broken (2026-04-20) because this line was missing. */@source"../../node_modules/@seo/components/src";
/* :root custom properties are fine unlayered — they only define
variables, they don't participate in the cascade against utilities. */:root {
--background: #ffffff;
--foreground: #1a1a1a;
--primary: #073c61; /* Main brand color (navy, blue, green, etc.) */--primary-dark: #052d49; /* Darker shade for footer, dark sections */--cta: #e11010; /* CTA button color (red, orange, etc.) */--cta-dark: #ae0c0c; /* CTA hover state */--accent: #d4a843; /* Accent/highlight color (gold, yellow, etc.) */--accent-light: #f0d88a; /* Light accent for badges */
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-primary: var(--primary);
--color-primary-dark: var(--primary-dark);
--color-cta: var(--cta);
--color-cta-dark: var(--cta-dark);
--color-accent: var(--accent);
--color-accent-light: var(--accent-light);
--font-sans: var(--font-inter);
--font-heading: var(--font-oswald);
}
/* CRITICAL: every non-variable rule below MUST live inside @layer base.
Unlayered declarations win over ALL layered declarations in the CSS
cascade, regardless of specificity. An unlayered `a { color: inherit }`
silently defeats every `text-*` Tailwind utility applied to an anchor
— which is exactly what shipped on c0nsl.com (2026-04-21): the "Book
a call" CTA rendered near-black on dark teal because globals.css had
a bare `a { color: inherit; text-decoration: none }` rule outside any
layer. Wrapping project styles in @layer base lets @layer utilities
win cleanly. If you find yourself writing a rule here without a
wrapping layer, stop and ask whether it belongs in base or whether
it should be a utility class instead. */@layer base {
body {
background: var(--background);
color: var(--foreground);
}
html {
scroll-behavior: smooth;
}
/* Example anchor reset — leave inside @layer base so .text-[var(--paper)]
or any other text-* utility can override it on CTA buttons. */a {
color: inherit;
text-decoration: none;
}
/* Any further project-specific helper classes (.prose-link, .kbd,
.display, .mono, background-image underline effects, ::selection,
body::before noise overlays, etc.) also go inside this block. */
}
Color naming convention: Use semantic names (primary, cta, accent) in the skill templates below. When building for a specific client, you can also add client-specific names (e.g., --color-navy, --color-red, --color-gold) for readability.
CSS layering rule (single source of truth):
Only :root { --var: ... } custom-property definitions, @theme inline { ... }, @layer ...; declarations, @import ...;, and @source ...; may sit outside a @layer block. Every other rule (element selectors, class selectors, pseudo-elements, body, html, ::selection, a, .prose-link, etc.) must be wrapped in @layer base { ... } (or @layer components { ... } for reusable component classes). @keyframes at the top level is allowed since it doesn't match selectors.
A Phase-8 automated check enforces this; see "Phase 8 contrast + layer audit" below.
2d. Configure Fonts and @seo/components (layout.tsx)
Use next/font/google with CSS variable mode. The body font goes on --font-sans, the heading font on --font-heading. Apply both variables to the <html> tag.
IMPORTANT: Route group architecture. The root layout must NOT include Header/Footer directly. Instead, use a (main) route group with its own layout for pages that need the site Header/Footer. SEO guide pages under /t/ also go inside (main) so they share the same Header/Footer as the rest of the site.
Root layout (src/app/layout.tsx): fonts, metadata, Organization JSON-LD, HeadingAnchors, and the three-column flex row that holds SitemapSidebar, {children}, and GuideChatPanel side-by-side. Header/Footer do NOT live here — they go in the (main) layout below.
import { Inter, Oswald } from"next/font/google";
import {
HeadingAnchors,
SitemapSidebar,
GuideChatPanel,
} from"@seo/components";
import { walkPages } from"@seo/components/server";
const inter = Inter({ variable: "--font-inter", subsets: ["latin"] });
const oswald = Oswald({ variable: "--font-oswald", subsets: ["latin"] });
exportdefaultfunctionRootLayout({ children }: { children: React.ReactNode }) {
const pages = walkPages();
return (
<htmllang="en"className={`${inter.variable} ${oswald.variable} h-fullantialiased`}>
{/* Body is min-h-full only. DO NOT add `flex flex-col` here — the flex row below is the layout. */}
<bodyclassName="min-h-full font-sans"><HeadingAnchors /><divclassName="flex min-h-screen"><SitemapSidebarpages={pages}brandName="CLIENT_NAME"hideOnPaths={["/"]} /><divclassName="flex-1 min-w-0 flex flex-col">
{children}
</div><GuideChatPanelapp="CLIENT_SLUG"hideOnPaths={["/"]} /></div>
{/* Organization JSON-LD here */}
</body></html>
);
}
HeadingAnchors auto-injects id attributes on H2 elements for sidebar linking and anchor navigation.
The three-column flex row is mandatory.SitemapSidebar and GuideChatPanel are both sticky top-0 h-screen shrink-0 asides. They MUST be DOM siblings of {children}inside a <div className="flex min-h-screen"> so the browser lays them out side-by-side. Anti-pattern that renders them invisible:<body className="... flex flex-col"> + <HeadingAnchors /> {children} <SitemapSidebar/> <GuideChatPanel/> as plain body siblings. In a flex-col body the asides stack below the full-height article (rectTop ≈ 8000–10000px) and sticky has nothing to anchor against. Both components still render with display:flex and the API health probe still returns 200, so no error surfaces; the site just silently ships with no sidebar and no page assistant. This is the 10xats.com 2026-04-24 failure mode.
Do not render <SeoComponentsStyles /> in the layout. That component injects a prebuilt Tailwind bundle wrapped in @layer seo-components, which collides with the consumer's own Tailwind classes sitting in @layer utilities (higher priority per the layer order in globals.css). The collision means library components with hidden xl:flex wrappers (GuideChatPanel, SitemapSidebar) are permanently display:none: the consumer's .hidden wins over the library's .xl:flex at every breakpoint. Phase 2c's @source "../../node_modules/@seo/components/src"; pragma already scans the library with the consumer's own Tailwind and is the only stylesheet you need.
Main layout (src/app/(main)/layout.tsx): wraps all pages with Header/Footer.
All page routes (homepage, about, wins, faq, precall, AND /t/ guide pages) go inside src/app/(main)/. The (main) directory is a Next.js route group: it does not affect URLs.
Layout smoke test (BEFORE moving on). Do not leave Phase 2d without visually confirming the three-column layout. Waiting until Phase 8 to catch a broken wiring costs hours of wasted section work:
Place any real /t/<slug>/page.tsx under src/app/(main)/t/ (one placeholder guide with a few H2s is enough). You need a non-homepage route because hideOnPaths={["/"]} suppresses both rails on /.
npm run dev and open http://localhost:3000/t/<slug> at viewport width ≥1280px.
Expect exactly two asides, both with top: 0. One is the left sidemap (left: 0, w: 288), the other is the right page assistant (left ≈ viewport width − 384, w: 384). Any top in the thousands means the aside is stranded below the article — stop and re-check the root layout.tsx for the <div className="flex min-h-screen"> wrapper BEFORE continuing to Phase 3.
Kill the dev server only after this check passes.
Font pairing guide:
Professional services: Inter + Oswald (clean, authoritative)
Creative/lifestyle: Lato + Playfair Display
Tech/SaaS: DM Sans + Space Grotesk
Legal/finance: Source Sans 3 + Merriweather
Phase 3: Build All Pages (Structure fixed, Visual design bespoke)
What this phase is now: the section stack order, component scaffolding (Header, Footer, section wrapper, FAQ, inner page set), and SEO infra are fixed across every client site. The visual treatment of each section is not prescribed and must be designed fresh from research/brand-identity.md, research/visual-references.md, and the design brief you produce in Phase 3.0 below.
Client sites must look distinct from one another. If your markup resembles another client site's markup, or reads as a generic corporate-agency template, you have failed Phase 3. Agents: read exemplars/claude-meter-editorial.html before writing markup, to calibrate what "distinctive, contemporary, product-first" looks like.
Appendix A (at the end of this file, formerly Phase 3d/g/h/i class dumps) contains one reference treatment: "Corporate B2B Safe Baseline". It is ONE example, not the mandate. Do not copy its Tailwind classes verbatim. Use it only if the design brief concludes that corporate-safe is actually the right answer for this client (rare; usually only for legal, finance, healthcare).
Brand identity still persists (re-read Phase 1.5i). Every user-facing string (Header wordmark, Footer copyright, metadata title/siteName/template, OpenGraph siteName, Organization JSON-LD name, WebSite JSON-LD name, email from labels, email subjects, page titles, HtmlSitemap brandName prop, page narrative copy) uses the brand name from research/brand-identity.md. The generic-domain stem only appears in URLs, email addresses, env vars, DB tables, and internal slugs. If you find yourself typing the stem into a title or JSON-LD name, you are making the rename mistake Phase 1.5i exists to prevent.
3.0. Design Brief (gate, no markup before this lands)
Before writing a single line of JSX for this site, produce research/design-brief.md answering all six items. If you can't fill an item, go back to Phase 1g and add another reference.
Design thesis: one sentence that names the visual identity (e.g. "Editorial Swiss on paper with live product mock", "Terminal-green brutalist with ASCII dividers", "Soft pastel Memphis with kinetic marquee"). Must be specific enough that two agents would produce converging palettes and motifs.
Palette: 4-6 hex codes with named roles: background, ink, accent, signal, optional muted and rule. Grounded in research/brand-identity.md. No arbitrary picks.
Type stack: at least two families (display + body), usually three (display + body + mono). All-sans is a red flag unless the brief explicitly argues for it. Justify each family against the thesis.
Signature motifs (at least 2): pick from the Phase 1g observations or invent new ones. Examples: numbered section eyebrows, hairline grid (not cards), pull quotes with oversized serif marks, paper grain overlay, italic serif for emphasis, live animated product mock, floating persistent chip, tilt parallax, copy-to-clipboard chips, kinetic type, ASCII dividers, marquee ribbons, asymmetric layouts.
Interactive moment (at least 1): hero animation tied to product state, scroll-reveal stagger, tilt-on-hover, scroll-driven sheen, copy-to-clipboard feedback, live-ticking numbers. Static pages age badly; give the site one thing that moves and rewards attention.
What this site will NOT do: list 3-5 patterns it deliberately rejects. Usually the list includes: "rounded-xl shadow-sm card grid", "centered-title + subtitle section headers", "gradient-text headlines", "generic hero with 'Primary Action' + 'Secondary Action' buttons". The point is to foreclose defaults.
Exit criteria for 3.0:research/design-brief.md exists, every item has a concrete answer, the thesis is different from every prior client's thesis (spot-check by reading 2-3 older briefs in ~/.claude/skills/setup-client-website/exemplars/briefs/ if that directory has accumulated entries).
3.1. Design DNA → Tailwind 4 theme tokens
The design brief's palette and type stack feed directly into globals.css:root + @theme inline (set up in Phase 2c). Every section after this point consumes only theme tokens: no hex codes inline, no ad-hoc font-families inline. This is the one rigid rule: if you find yourself writing style={{ color: "#E8471C" }} in a section component, stop and add it to :root as a named variable first.
Semantic token names should follow the brief's role names (e.g. --paper, --ink, --accent, --signal, --rule, --muted), not generic Tailwind defaults (--primary-50, --gray-900). Role-named tokens make the design brief and the CSS mutually readable.
3a. Header Component Blueprint
Sticky nav with logo, dropdown menus, mobile hamburger, and CTA button.
Reading order: the section stack order below is fixed for every client. The Tailwind classes inside each section are ONE reference treatment, not the mandate. After Phase 3.0 lands the design brief, reinterpret each of these seven sections in the visual identity the brief defines. Do not paste the classes verbatim. If your markup reads as a copy of this reference, it fails Phase 3.
Treat this section the way a musician reads sheet music for a jazz standard: the chord changes are fixed (hero → strip → stats → benefits → process → testimonials → CTA), the voicing and phrasing are yours.
Usage: Render in a <div className="space-y-4"> container within a bg-gray-50 section. Always include JSON-LD FAQPage schema alongside.
3g. Case Study / Wins Page Blueprint (REFERENCE EXAMPLE)
Same rule as 3d: structure fixed, visual treatment bespoke. Featured tier + additional tier is the pattern; the cards below are one rendering. If the design brief names "pull quotes with serif marks" as a motif, render case studies as pull quotes, not shadow cards. Always skip this page entirely if the client has no case studies to tell yet; a page with three fabricated wins is worse than no page.
Two tiers: featured case studies (detailed cards) and additional testimonials (grid).
3h. Book a Call / Precall Page Blueprint (REFERENCE EXAMPLE)
Structure fixed, visual treatment bespoke. The two-column scheduling + proof pattern is the pattern; classes below are one rendering. For product-led sites (Claude Meter, developer tools, self-serve SaaS), this page is often replaced by /install or /signup with a copy-to-clipboard chip; see the Claude Meter exemplar. Use your judgment from the design brief on whether book-a-call is actually the right CTA for this client.
Two-column layout: left (2/3) has video + scheduling widget, right (1/3) has testimonials sidebar.
Structure fixed, visual treatment bespoke. The section order below is the pattern. "Dark Hero + Glass Card" is one rendering; reinterpret in the brief's identity. Product-led / open-source sites may replace About entirely with a single "Why / Who built this" editorial page.
Section order: Dark Hero, Stats Bar, Founder Story (prose with photo), Team Photo, Values Grid (2-col), "Who We Serve" Checklist Grid (3-col), Contact CTA (dark bg with glass card).
3p. SEO Infrastructure (XML sitemap + robots + HTML sitemap: ALL THREE REQUIRED, DYNAMIC, NEVER OPTIONAL)
Every client site MUST ship all three of the following. They come from @m13v/seo-components (v0.22.0+) as one-liners. Do NOT hand-roll any. Do NOT skip any. A site missing any of the three is considered broken and fails Phase 8.
src/app/sitemap.ts — XML sitemap at /sitemap.xml (for crawlers: Google, Bing, ChatGPT, Perplexity)
src/app/robots.ts — robots.txt at /robots.txt (for crawler directives, references the XML sitemap URL)
src/app/sitemap/page.tsx — HTML sitemap at /sitemap (for humans: a normal clickable page listing every URL, grouped by section)
All three must coexist. Users who type /sitemap into a browser expect a readable page; crawlers hitting /sitemap.xml expect the XML feed; robots.txt is what tells crawlers where the XML feed lives. Missing any one of them is a silent SEO and UX failure — if a user asks "why is /sitemap a 404?" after launch, this step was skipped.
generateRobots() emits User-agent: * with allow: "/" and disallow: ["/api/"], then per-agent allow: "/" rules for GPTBot, ChatGPT-User, ClaudeBot, Claude-Web, anthropic-ai, PerplexityBot, CCBot, Google-Extended, Bytespider, cohere-ai, Applebot, and Applebot-Extended, plus sitemap: "https://DOMAIN/sitemap.xml". Override any of these via disallow, aiAllowlist, extraRules, or sitemap when you need custom behavior.
src/app/sitemap/page.tsx (human-readable HTML sitemap at /sitemap, uses the HtmlSitemap component; coexists with /sitemap.xml because Next.js routes sitemap.ts → /sitemap.xml and sitemap/page.tsx → /sitemap — not a conflict):
Local verification before moving to the next phase (ALL FOUR checks MUST pass, no exceptions):
# Dev server must be running at http://localhost:3000
curl -s -o /dev/null -w "sitemap.xml: %{http_code}\n" http://localhost:3000/sitemap.xml # 200
curl -s -o /dev/null -w "robots.txt: %{http_code}\n" http://localhost:3000/robots.txt # 200
curl -s -o /dev/null -w "sitemap: %{http_code}\n" http://localhost:3000/sitemap # 200 (HTML page, not 404)
curl -s http://localhost:3000/sitemap.xml | grep -c "<url>"# > 0
curl -s http://localhost:3000/robots.txt | grep -q "Sitemap: http.*sitemap.xml" && echo OK # OK
curl -s http://localhost:3000/sitemap | grep -q "<html" && echo"HTML sitemap OK"# HTML sitemap OK
grep -lE "walkPages|fs\.readdirSync" src/app/sitemap.ts src/app/robots.ts || echo"OK no hand-roll"# both must be one-line generateSitemap/generateRobots calls; matches = forbidden hand-roll (Cyrano + PieLine 2026-04-30)
If any check fails, stop and fix before continuing. A missing sitemap (XML or HTML) is the #1 silent SEO + UX breakage across client sites — the c0nsl.com 2026-04-21 post-launch 404 on /sitemap was caused by skipping the HTML sitemap step.
llms.txt (required). AI crawlers (ChatGPT, Perplexity, Claude, Google AI Overviews) look for /llms.txt to get a curated, plain-text brief. Every site in config.json has an llms_txt entry; downstream pipelines assume the file exists.
Convention: ship a static file at public/llms.txt (matches fazm, cyrano, fde10x). Do not build a Next.js route for it; static is simpler and caches at the CDN edge.
Template (swap BRAND / one-liner / bullets; keep it under ~300 lines):
# BRAND
One-sentence positioning angle. What the product is, for whom, with the concrete outcome.
## What it does
Two or three sentences of plain description. No marketing adjectives.
## What makes it different
1. Differentiator one, with a specific mechanism or number.
2. Differentiator two.
3. Differentiator three.
## Proof points
- Named case study or public artifact with a verifiable link.
- Named case study or public artifact with a verifiable link.
## Who it's for
ICP in one paragraph. Name the role, the trigger, and the stakes.
## Links
- Website: https://DOMAIN
- Install / book / download: https://DOMAIN/<primary-cta>
- GitHub (if public): https://github.com/<org>/<repo>
After creating, verify it serves at the production URL: curl -sS https://DOMAIN/llms.txt | head must return the file, not a 404.
Cross-check config.json: the llms_txt field for this project must point at ~/<repo>/public/llms.txt (absolute-from-home path), matching the filesystem location.
Phase 3.5: Integrations (PostHog + Resend + Neon)
Every client site gets the same three integrations: PostHog for analytics, Resend for transactional email, and Neon for a lightweight relational store. The contract matches a prior site (use your reference repo). The pattern is fixed: port it verbatim, change only the brand strings and the from address.
Why these three:
PostHog: required for @seo/components NewsletterSignup + TrackedCta. The NewsletterSignup component calls window.posthog?.capture("newsletter_subscribed", ...) on success, so PostHog must be globally attached before the component mounts. Mount <FullSiteAnalytics> from @m13v/seo-components once at the root layout (see 3.5b); it handles window.posthog attachment for you. Works identically whether the site shares the catchall project or has its own.
Resend: /api/newsletter adds the subscriber to an audience and fires a welcome email. /api/contact replaces mailto: with a server-validated submission that also logs to Neon. Inbound webhook stores replies and forwards them to you@example.com.
Neon: @neondatabase/serverless for subscriber/email logs. No pool, no lifecycle. One DATABASE_URL, tagged-template SQL.
Scope note: Phase 3.5d's Book-a-Call helpers and Phase 3.5l's Cal.com event type creation are [opt-in: book-a-call]. Skip both unless the invoker requested Book-a-Call — see "Optional scope flags" above.
Hard rule for all external IDs/keys: every phc_..., audience UUID, Neon URL, Cal.com slug is pulled from keychain or live API response and substituted into the real file. Never commit a placeholder string like phc_REPLACE_ME_... or REPLACE_WITH_..._PROJECT_ID into .env.production or config.json. If the real value isn't available yet, stop Phase 3.5 here — do not proceed to Phase 6 with placeholders. Placeholders have shipped to production twice (fde10x 2026-04-19, claude-meter 2026-04-20) and silently dark-launched PostHog for the site.
framer-motion is already required by NewsletterSignup; install it even if nothing else needs it yet.
3.5b. PostHog analytics wiring: mount <FullSiteAnalytics> (the only path)
Every new client site uses <FullSiteAnalytics> from @m13v/seo-components. Mount it once in the root src/app/layout.tsx and you are done, no hand-rolled provider, no site group tag, no SITE_ID env var.
What <FullSiteAnalytics> does for you: (1) posthog.init(...) with session recording, person-profiles-identified-only, pageview + pageleave capture; (2) attaches the client to window.posthog so library helpers (NewsletterSignup, trackScheduleClick, trackGetStartedClick, BookCallCTA, GetStartedCTA, any CTA with trackAs=) fire correctly; (3) wraps children in PHProvider + SeoAnalyticsProvider. Do not reimplement any of this by hand.
Segmentation across shared-project sites is automatic via properties.$host. Every PostHog event already carries the browser's hostname under $host. scripts/project_stats_json.py filters the shared catchall project by $host alone, one clause per config.json website. No site group tag, no NEXT_PUBLIC_POSTHOG_SITE_ID, no ph.register({ site }) ceremony is needed or read by anything downstream. The old hand-rolled provider pattern (with ph.group("site", SITE_ID)) produced group tags that nothing ever queried.
Env var contract for shared-project sites:
NEXT_PUBLIC_POSTHOG_KEY — the shared project's phc_... token. Identical across every client site that piggybacks on the same project. Lookup: the keychain entry named in config.json > defaults.posthog_phc_token_keychain.
Why a shared project at all: the m13v PostHog org has an 8-project hard cap, and creating a new project per client blows past it. Because segmentation is pure $host, sharing one project across N client sites costs nothing at query time.
3.5c-verify. Verify window.posthog is set (MANDATORY)
After deploying (or running dev locally), open DevTools console on / and type:
window.posthog
It must return an object (the PostHog client instance), not undefined. If it is undefined:
@m13v/seo-components analytics (NewsletterSignup, TrackedCta, any library component that calls window.posthog?.capture) is silently broken.
Root cause is almost always: the site is not mounting <FullSiteAnalytics> at the root layout (it does the window.posthog attachment for you). Revisit 3.5b and make sure the <FullSiteAnalytics> wrapper sits inside <body> in src/app/layout.tsx.
This is a regression guard for the exact bug that dropped analytics on three of four sites prior to @m13v/seo-components v0.16.0 (hand-rolled providers that initialised posthog-js via ESM but never assigned it to window.posthog).
The stats pipeline at ~/social-autoposter/scripts/project_stats_json.py queries PostHog for two specific event names, scoped by properties.$host:
cta_click (underscore, no "d" at the end) — fired on any marketing CTA
schedule_click — fired on CTAs that route to a booking tool (Cal.com, Calendly, meetings.hubspot.com)
Every CTA on a new client site MUST fire cta_click. Every Book-a-Call / schedule / demo CTA MUST fire both cta_click AND schedule_click. Do not invent new event names like cta_clicked; they will be invisible to the dashboard.
Every Book-a-Call CTA MUST also carry per-page attribution. The Cal.com webhook at social-autoposter-website/src/app/api/webhooks/cal/route.ts already extracts booking.metadata.utm_source / utm_medium / utm_campaign and writes them to cal_bookings.utm_*. The path that populates those fields is client-side: every Book-a-Call link must route its href through withBookingAttribution from @seo/components (v0.22+), which appends Cal.com's metadata[utm_*] query params using the current hostname + pathname. Used automatically by BookCallCTA, and by InlineCta / StickyBottomCta when trackAs="schedule", so consumers don't need to call it directly. Never render a raw <a href="https://cal.com/..."> or a hand-rolled Book-CTA component that doesn't go through withBookingAttribution. If you do, page-level booking attribution breaks and the top-pages SEO pipeline can't score bookings per page.
Canonical helper from @m13v/seo-components (v0.14+):
Swap every hand-rolled <Link href="/contact"> for <TrackedCta href="/contact" page="home" section="hero">. Unique page + section per instance keeps PostHog funnels distinguishable.
Book-a-Call helper[opt-in: book-a-call] — skip this whole sub-section (through the end of 3.5d) unless the invoker asked for Book-a-Call. Sites without a Book CTA (free OSS tools, install-driven products) should not add BookCallLink/BookCallTracker at all. Required for any CTA that points at cal.com / calendly.com — src/lib/booking.ts + src/components/BookCallLink.tsx:
// src/lib/booking.ts"use client";
import { trackScheduleClick } from"@seo/components";
import { posthog } from"@/components/posthog-provider";
// Created via the Cal.com v2 API in Phase 3.5l. <TEAM_SLUG> comes from// `defaults.cal_team_slug` in ~/social-autoposter/config.json; per-client slugs// live under the shared team so the Phase 6g webhook covers every client.exportconstBOOKING_URL = "https://cal.com/team/<TEAM_SLUG>/<SLUG>";
exportfunctiontrackBookingClick(opts: { section?: string; text?: string; component?: string }) {
const page = typeofwindow !== "undefined" ? window.location.pathname : undefined;
posthog?.capture("cta_click", { page, href: BOOKING_URL, text: opts.text, section: opts.section });
trackScheduleClick({
destination: BOOKING_URL, site: "<SLUG>",
section: opts.section, text: opts.text,
component: opts.component ?? "BookCallLink",
});
}
// src/components/BookCallLink.tsx"use client";
import { useEffect, useState } from"react";
importLinkfrom"next/link";
import { withBookingAttribution } from"@seo/components";
import { BOOKING_URL, trackBookingClick } from"@/lib/booking";
exportfunctionBookCallLink({ children, className, section, onClick }: {
children: React.ReactNode; className?: string; section?: string; onClick?: () => void;
}) {
const text = typeof children === "string" ? children : undefined;
// Swap in the UTM-attributed URL after hydration so the Cal.com webhook can// write cal_bookings.utm_source / utm_medium / utm_campaign. PostHog events// still carry the bare BOOKING_URL via trackBookingClick for clean grouping.const [href, setHref] = useState(BOOKING_URL);
useEffect(() => { setHref(withBookingAttribution(BOOKING_URL)); }, []);
return (
<Linkhref={href}target="_blank"rel="noopener noreferrer"className={className}onClick={() => { trackBookingClick({ section, text, component: "BookCallLink" }); onClick?.(); }}
>{children}</Link>
);
}
For third-party components that don't accept an onClick prop (e.g. ShimmerButton, InlineCta from @seo/components), wrap them in a client-only capture span — src/components/BookCallTracker.tsx:
<BookCallTracker section="topic-inline" component="InlineCta">
<InlineCtaheading="..."body="..."linkText="Book the call"href={BOOKING_URL} />
</BookCallTracker>
Every Book-a-Call CTA — header, hero, footer, FAQ, per-section, and any topic page — must be one of <BookCallLink> or wrapped in <BookCallTracker>. This is how the stats pipeline counts schedule_click per $host.
Get-Started helper[opt-in: get-started] — skip this whole sub-section unless the invoker asked for Get-Started. Required for the site's primary self-serve CTA, regardless of whether that's a download (Mac .dmg, App Store, /download), an install (/install, Chrome Web Store), or a signup (app.<domain>, waitlist, trial start). All three flavors fire the same canonical get_started_click event so the dashboard funnel is one column, not three. Scaffold src/lib/get-started.ts + src/components/GetStartedLink.tsx:
For third-party components that don't accept an onClick prop (e.g. ShimmerButton), wrap them in a client-only capture span — src/components/GetStartedTracker.tsx:
Alternatively, drop in the library's <GetStartedCTA> which fires get_started_click automatically, or pass trackAs="get_started" to the generic <InlineCta> / <StickyBottomCta> so their click also counts in the funnel:
Every primary self-serve CTA — header, hero, footer, FAQ, per-section, and any topic page — must be one of <GetStartedLink>, wrapped in <GetStartedTracker>, rendered as <GetStartedCTA>, or given trackAs="get_started". This is how the stats pipeline counts get_started_click per $host.
If both flags are on: the two CTAs live side by side in the hero and in section footers (one primary, one secondary). See Fazm for the canonical layout — book-a-call for the pilot path, get-started for the self-serve path. Assrt follows the same pattern with a SaaS signup entry rather than a download.
3.5d″. Gated-redirect scaffold [opt-in: gated-redirect]: replaces <GetStartedLink> when the destination must stay hidden
Skip this entire sub-section unless gated-redirect is on. When it IS on, do NOT scaffold src/lib/get-started.ts as a client module exporting GET_STARTED_URL; any client component that imports it will ship the URL to the browser, defeating the gate. Instead:
Move the destination URL to the server. Create src/lib/redirect.ts that defines const APP_BASE = "https://<destination>" and a server-only buildRedirect(slug: string, email: string): string that appends UTM params. This module is only ever imported from src/app/api/signup/route.ts. Never re-export APP_BASE from a "use client" file.
Build src/components/EmailGateModal.tsx. A single dialog component with a state machine (idle → submitting → success → error), an email input, a Continue button, and one piece of copy that does NOT name the destination. Expose a global window.__<slug>Gate = { open(slug) } so any CTA on the page can trigger it without prop-drilling. Mount once via <EmailGateModalRoot /> in (main)/layout.tsx. Studyly's src/components/EmailGateModal.tsx is the working reference.
Build src/app/api/signup/route.ts. POST handler that takes { email, slug, path, referrer }, validates the email, and does FOUR things, in order, all best-effort except the redirect. Reference: ~/studyly-website/src/app/api/signup/route.ts.
Insert into Neon (<table_prefix>_signups).
Upsert into the per-client Resend audience.POST https://api.resend.com/audiences/${RESEND_AUDIENCE_ID}/contacts with { email, unsubscribed: false }. This step is mandatory. Resend dedupes on email so it's idempotent. The route MUST guard on RESEND_AUDIENCE_ID being set and log loudly when it isn't (audience-empty was the studyly 2026-04-28 bug; gated-redirect routes silently skipped this step and no subscriber was retargettable).
Send Resend transactional welcome whose body contains the access link (the destination URL is fine HERE, post-gate).
Return { ok: true, redirect: <url> } so the client can window.location.href = redirect after ~800ms.
All four envs must be on the production runtime (Cloud Run / Vercel): DATABASE_URL, RESEND_API_KEY, RESEND_AUDIENCE_ID, plus any per-client redirect-builder envs. .env.local is not enough; the deploy target reads its own env. After gcloud run services update or vercel env add, run a verify step (gcloud run services describe ... --format="value(spec.template.spec.containers[0].env)" or vercel env pull) and confirm RESEND_AUDIENCE_ID is present with NO trailing \n. The studyly bug shipped with the env entirely missing on Cloud Run; the route silently no-op'd the audience write for the first day after launch.
Replace every Get-Started CTA on the site with a button that fires get_started_click AT CLICK TIME and opens the modal. The click event must be captured on the button itself, not deferred to post-submit, so the dashboard's "Get Started" column counts gate-open intent (not just successful conversions). EmailGateModal fires the second event (newsletter_subscribed) on successful submit, populating "Email Signups". Both events together = full gated-redirect funnel.
Reference: ~/studyly-website/src/components/GetStartedButton.tsx. The Resend send and the actual redirect can fail without losing this funnel data point.
4a. EmailGateModal MUST fire newsletter_subscribed on successful /api/signup response. This is the canonical PostHog event the dashboard reads for the "Email Signups" column (scripts/funnel_per_day.py and scripts/project_stats_json.py query event = 'newsletter_subscribed'). The gate IS an email capture; the redirect is incidental. Without this event, a gated-redirect site shows 0 in Email Signups forever.
// After fetch('/api/signup') succeeds, before window.location.href = data.redirect:window.posthog?.capture?.("newsletter_subscribed", {
component: "EmailGateModal",
slug,
path: window.location.pathname,
});
Fine print: "By continuing you agree to our terms and privacy policy. No spam."
Privacy/Terms templates. Use the operating entity name (<Brand> Inc.) for legal disclosure and never print the literal app URL. "We share your email with <Brand> Inc. so they can provision your account on the study app." NOT "…on app..com." For governing terms link out to a mailto:hello@<our-domain> request channel rather than <brand>.com/terms-of-service.
config.json shape. With gated-redirect on, get_started_link still holds the actual destination URL (the dashboard stats pipeline reads it for the "Get Started" funnel column and the DM bot uses it when the operator pastes the link manually). But landing_pages.product_source[].description should describe the gate flow without naming the URL, since the operator dashboard renders that field on internal pages.
3.5e. NewsletterSignup — drop into (main)/layout.tsx
import { NewsletterSignup } from"@seo/components";
// ... inside the main layout return, after Footer:<NewsletterSignupdescription="Short pitch (~10 words). What does the reader get?"buttonLabel="Subscribe"successMessage="Subscribed. Check your inbox."
/>
The component POSTs to /api/newsletter by default. 2xx = success (shows success message); non-2xx expects JSON {error: "..."}. On success it calls window.posthog.capture("newsletter_subscribed", ...).
3.5f. Resend helper — src/lib/resend-server.ts
Port verbatim from ~/your-prior-site/src/lib/resend-server.ts. Change only the default from address to <Brand Name> <<SENDER_LOCAL>@DOMAIN> (where <SENDER_LOCAL> is defaults.sender_local_part from ~/social-autoposter/config.json, the local part of the shared sender mailbox). The helper uses the REST API directly (no SDK), provides sendEmail() and addToAudience(), and fails soft when env vars are missing.
3.5g. Neon helper — src/lib/db.ts
import { neon } from"@neondatabase/serverless";
exportfunctiongetSql() {
const url = process.env.DATABASE_URL;
if (!url) thrownewError("DATABASE_URL not set");
returnneon(url);
}
Use tagged templates at call sites: const sql = getSql(); await sql`INSERT INTO ...`. No pool, no connection lifecycle.
3.5h. PostHog server helper — src/lib/posthog-server.ts (optional)
Port verbatim from your reference repo when any server route needs captureServer(). Not required for the minimum wiring.
Use the createNewsletterHandler factory from @seo/components/server (v0.18.1+). It validates email, adds to the Resend audience, sends a welcome email that includes a "Book a 15-min call" primary CTA, and gives you an onSignup hook for Neon logging + server-side PostHog capture.
STOP. Before writing this route, the client domain must already be added to Resend, have DKIM/SPF/MX records live in the Cloud DNS zone, and show status: verified in Resend. If it's not, go complete the Phase 3.5 Resend domain-verification block now. Without it the audience upsert succeeds, the handler returns {success:true}, and Resend silently 403s the welcome send (console-logged only) — subscribers never see mail.
import { createNewsletterHandler } from"@seo/components/server";
import { getSql } from"@/lib/db";
exportconstPOST = createNewsletterHandler({
audienceId: process.env.RESEND_AUDIENCE_ID || "<hardcode-from-phase-6d>",
fromEmail: "<Sender> from BRAND <<SENDER_LOCAL>@DOMAIN>", // SENDER_LOCAL = defaults.sender_local_partbrand: "BRAND",
siteUrl: "https://DOMAIN",
// REQUIRED for attribution: the per-client Cal.com team event URL created in// Phase 6g (`cal.com/team/<TEAM_SLUG>/<SLUG>`, where TEAM_SLUG =// `defaults.cal_team_slug` from ~/social-autoposter/config.json). Without this,// welcome-email bookings land under a generic event type (e.g. 15min) and the// stats pipeline can't attribute them to this client.bookingUrl: "https://cal.com/team/<TEAM_SLUG>/<SLUG>",
// Rename `<slug>_emails` (e.g. `fde10x_emails`) so multiple clients can share// one Neon instance without collisions.onSignup: async (email, resendEmailId) => {
try {
const sql = getSql();
await sql`
INSERT INTO <slug>_emails (resend_id, direction, from_email, to_email, subject, status)
VALUES (${resendEmailId}, 'outbound', '<SENDER_LOCAL>@DOMAIN', ${email}, 'Welcome email', 'sent')
`;
} catch (err) { console.error("newsletter log error:", err); }
// Server-side belt-and-suspenders: also fire newsletter_subscribed from the// server. NewsletterSignup fires it client-side, but a failed hydration or// ad blocker would swallow that. Server-side guarantees the event lands.try {
const { getPostHogServer } = awaitimport("@/lib/posthog-server");
const ph = getPostHogServer();
ph?.capture({
distinctId: email,
event: "newsletter_subscribed",
properties: { source: "api/newsletter", site: "<SLUG>" },
});
await ph?.flush();
} catch (err) { console.error("newsletter posthog server-capture error:", err); }
},
});
bookingUrl is required for correct Cal.com attribution: without it, welcome-email bookings route through a generic event type and end up as client_slug='unknown' in cal_bookings. Skipping it is the bug class that let Liam's 2026-04-17 s4l booking go untracked.
Replaces mailto:hello@DOMAIN. Validates, sends notification email to you@example.com, stores inquiry in Neon, returns {ok: true} for the client form handler to render a success state.
Every client site MUST wire inbound. The default from address is a real human address (matt@<domain> per defaults.sender_local_part), so users WILL hit Reply. Without inbound, replies bounce silently and you lose every "redirect broke", "I forgot my password", "can I get a refund" message that recipients send. Studyly 2026-05-05 shipped without inbound and burned 6 captured signups before someone noticed; do not repeat.
The pipeline (mirrors fazm.ai, s4l.ai, assrt.ai, mk0r.com, cl0ne.ai, vipassana.cool):
Apex MX 10 inbound-smtp.us-east-1.amazonaws.com. lands inbound mail at Resend (built on AWS SES).
Resend POSTs an email.received event to https://<domain>/api/webhooks/resend.
Webhook handler: (a) fetches the full body via https://api.resend.com/emails/receiving/<id>, (b) inserts into <slug>_emails (direction='inbound'), (c) forwards to defaults.inbound_forward_email (read from ~/social-autoposter/config.json, currently i@m13v.com) so a human actually sees it.
Same handler also receives delivery events (sent/delivered/opened/clicked/bounced) and updates the corresponding outbound row.
Steps (do all four in order, do not skip):
# 1. Enable receiving on the Resend domain (additive to sending; idempotent).
RESEND_KEY=$(security find-generic-password -l "$(jq -r '.defaults.resend_master_key_keychain' ~/social-autoposter/config.json)" -w)
DOMAIN_ID=<from Phase 3.5 add-domain output>
curl -s -X PATCH -H "Authorization: Bearer $RESEND_KEY" -H "Content-Type: application/json" \
-d '{"capabilities":{"receiving":"enabled"}}' \
"https://api.resend.com/domains/$DOMAIN_ID" | python3 -m json.tool
# 2. Add the inbound MX on the apex (Phase 6e DNS block already includes this — see there for the gcloud command).# 3. Re-verify so Resend picks up the new MX:
curl -s -X POST -H "Authorization: Bearer $RESEND_KEY" \
"https://api.resend.com/domains/$DOMAIN_ID/verify"# Poll until the Receiving record reads status=verified:
curl -s -H "Authorization: Bearer $RESEND_KEY""https://api.resend.com/domains/$DOMAIN_ID" \
| python3 -c "import json,sys;d=json.load(sys.stdin);print([r for r in d['records'] if r['record']=='Receiving'])"# 4. Register the webhook endpoint with all delivery + receiving events:
curl -s -X POST -H "Authorization: Bearer $RESEND_KEY" -H "Content-Type: application/json" \
-d '{"endpoint":"https://<DOMAIN>/api/webhooks/resend","events":["email.received","email.sent","email.delivered","email.delivery_delayed","email.bounced","email.complained","email.opened","email.clicked"]}' \
"https://api.resend.com/webhooks" | python3 -m json.tool
# → save the returned signing_secret to keychain as resend-<slug>-webhook-secret
SIGNING_SECRET=<from response>
security add-generic-password -U -a "you@example.com" -s "resend-<SLUG>-webhook-secret" -w "$SIGNING_SECRET"
Webhook handler code: Port ~/studyly-website/src/app/api/webhooks/resend/route.ts verbatim. Only changes:
Replace every studyly_emails with <slug>_emails.
Replace the endsWith("@studyly.io") guard with the client domain.
Replace the STUDYLY_INBOX_FORWARD || "i@m13v.com" default with <SLUG>_INBOX_FORWARD || <jq -r '.defaults.inbound_forward_email' from config.json>.
Keep the signups lookup if the site uses gated-redirect; otherwise drop it (the reference table for inbound linkage is <slug>_emails itself).
Outbound logging. Every /api/signup, /api/newsletter, and /api/contact route MUST also insert an outbound row into <slug>_emails so delivery events can update the same row by resend_id. Without this, opens/clicks/bounces have no row to update and silently no-op. Reference: ~/studyly-website/src/app/api/signup/route.ts (the second INSERT INTO studyly_emails (..., direction='outbound', ..., status='sent') block after the resend.emails.send call).
<slug>_emails schema (already required by Phase 3.5l shared-resource block; this section just clarifies the indices the inbound handler relies on):
CREATE TABLE IF NOTEXISTS<slug>_emails (
id BIGSERIAL PRIMARY KEY,
resend_id TEXT UNIQUE,
direction TEXT NOT NULLCHECK (direction IN ('inbound','outbound')),
from_email TEXT NOT NULL,
to_email TEXT NOT NULL,
subject TEXT,
body_text TEXT,
body_html TEXT,
status TEXT,
utm_source TEXT, utm_medium TEXT, utm_campaign TEXT,
signup_id BIGINTREFERENCES signups(id) ONDELETESETNULL, -- only if gated-redirect is on
created_at TIMESTAMPTZ NOT NULLDEFAULT now(),
delivered_at TIMESTAMPTZ, opened_at TIMESTAMPTZ, clicked_at TIMESTAMPTZ
);
CREATE INDEX IF NOTEXISTS idx_<slug>_emails_to ON<slug>_emails(to_email, created_at DESC);
CREATE INDEX IF NOTEXISTS idx_<slug>_emails_from ON<slug>_emails(from_email, created_at DESC);
Exit criterion (MANDATORY end-to-end test before Phase 9):
# Send a real email FROM your i@m13v.com inbox TO matt@<DOMAIN>:
/opt/homebrew/bin/python3.11 -c "
import sys; sys.path.insert(0, '/Users/matthewdi/gmail-api')
from gmail_dwd_client import gmail_for
print(gmail_for('i@m13v.com').send_message(to='matt@<DOMAIN>', subject='inbound test', body='roundtrip test'))
"# Within ~30s, verify ALL THREE of:# (a) Cloud Run logs show '[<Slug> Webhook] email.received <id>'
PSQL=/opt/homebrew/bin/psql
$PSQL"$(security find-generic-password -l "neon-<SLUG>-pooled-url" -w)" \
-c "SELECT direction, from_email, to_email, subject, status FROM <slug>_emails WHERE direction='inbound' ORDER BY created_at DESC LIMIT 1;"# (b) the row above shows direction='inbound', from='i@m13v.com', status='received'# (c) i@m13v.com inbox received a message subject-prefixed '[<Slug> Inbound]'
If any of (a/b/c) fail, do NOT proceed to Phase 9. Inbound is the ONLY way a real human sees customer replies; a silent failure here means lost revenue and lost trust.
MANDATORY for any site with booking_link in config.json. This is the redirect that closes the loop on which DM (or post, or pasted-anywhere link) produced a Cal.com booking. The DM bot mints https://<website>/r/<code> per outbound thread; the route looks the code up in the social-autoposter Neon dms table, increments a click counter, and 302s to Cal.com with metadata[utm_content]=dm_<id> so the existing /api/webhooks/cal handler fills cal_bookings.utm_* per DM.
Skip this section only if book-a-call is off for this site.
<SLUG> is the lowercase project slug (e.g. pieline, assrt, fazm) — must match the name field in config.json after lowercasing and replacing spaces with -. The slug is reported on the PostHog dm_short_link_clicked event and is how the dashboard splits clicks per site.
The factory hits https://app.s4l.ai/api/short-links/<code> by default. Override per-environment via SHORT_LINK_RESOLVER_URL env var (rarely needed). Requires NEXT_PUBLIC_POSTHOG_KEY to be set on the deploy for PostHog click tracking; redirect itself works without it.
No code changes required after scaffold — the route file is the entire integration. Verify post-deploy by minting a test code on a real DM (python3 ~/social-autoposter/scripts/dm_short_links.py mint --dm-id N --json) and curling the printed short URL with -I to confirm the 302 lands on Cal.com with metadata[utm_content]=dm_N.
Default provisioning model: reuse shared infrastructure, mint only the per-client subset resources. Creating a new PostHog project, Neon DB, and Resend API key for every client was the historical pattern but hits org caps, bloats the keychain, and makes cross-site analytics a chore. Shipping a new client now means reusing the shared project/DB/key and only creating the narrow per-client slice (prefixed Neon tables, Resend audience, Cal.com event type). PostHog has no per-client slice at all; segmentation is pure properties.$host.
Master keychain entries (reused for every client). Entry names are defined in config.json > defaults.*_keychain; never hardcode them in the skill:
Service
defaults.*_keychain key
Scope
PostHog
posthog_personal_api_key_keychain
Personal API key (lists projects, creates keys)
Neon
neon_provisioning_token_keychain
Account key (rarely needed; only for a net-new DB)
Resend
resend_master_key_keychain
Full-access account key — this IS the runtime RESEND_API_KEY for every client site
Shared runtime credentials (reused for every client). Looked up at deploy time via config.json > defaults:
defaults key
What it holds
Env var on Cloud Run
posthog_phc_token_keychain
Shared project phc_... token
NEXT_PUBLIC_POSTHOG_KEY
neon_pooled_url_keychain
Shared Neon pooled DATABASE_URL
DATABASE_URL
resend_master_key_keychain
Master Resend full-access key
RESEND_API_KEY
Per-client artifacts created by this phase (the only new things):
Artifact
Shape
Where it lives
Neon tables <slug>_emails, <slug>_contacts
DDL run against the shared DB
Shared Neon project, isolated by table prefix
Resend audience
UUID saved in keychain as resend-<slug>-audience-id
Runtime env var RESEND_AUDIENCE_ID
Cal.com event type
Slug under the shared Cal team
URL https://cal.com/team/<cal_team_slug>/<slug> in booking button
PostHog has no per-client artifact. Every client site mounts <FullSiteAnalytics> pointed at the shared project's phc_..., and segmentation in the dashboard is pure properties.$host.
PostHog — reuse the shared project; segmentation is automatic via properties.$host.
Every client site sends events to the same shared PostHog project (config.json > defaults.posthog_project_id / posthog_project_name). The dashboard splits sites apart by filtering on properties.$host (every event already carries the browser hostname). There is no site group tag, no NEXT_PUBLIC_POSTHOG_SITE_ID, and no per-client PostHog artifact. scripts/project_stats_json.py is the authoritative consumer and queries $host exclusively.
Do NOT try to create a new project — the PostHog org is at its project cap and it returns You have reached the maximum limit of allowed projects. The shared phc_... token lives in the keychain entry named by defaults.posthog_phc_token_keychain; use it verbatim for NEXT_PUBLIC_POSTHOG_KEY in the new client's .env.production.
CFG=~/social-autoposter/config.json
PH_KEY=$(security find-generic-password -l "$(jq -r '.defaults.posthog_personal_api_key_keychain' "$CFG")" -w)
# List existing projects and their api_tokens so you can confirm the shared project id.
curl -s -H "Authorization: Bearer $PH_KEY""$(jq -r '.defaults.posthog_host' "$CFG")/api/projects/" \
| python3 -c "import json,sys; [print(f\"{p['id']:<7} {p['name']:<20} {p['api_token']}\") for p in json.load(sys.stdin).get('results',[])]"# Fetch the shared phc_... for the new client's .env.production.
security find-generic-password -l "$(jq -r '.defaults.posthog_phc_token_keychain' "$CFG")" -w
There is no per-client keychain entry on the PostHog side — every client on the shared project uses the same phc_....
Rare exception: dedicated project. If the client explicitly wants an isolated PostHog project (e.g. for customer-facing analytics exports), and there's room in the org cap, create one with the org API and point that client's NEXT_PUBLIC_POSTHOG_KEY at the dedicated project's token. No other code change is needed, <FullSiteAnalytics> works identically either way. Most clients do NOT need this; use the shared pattern unless told otherwise.
Neon — reuse the shared DB, isolate with per-client table prefixes.
Every client site writes to the same Neon project. Isolation is purely by table name: tables are named <slug>_emails and <slug>_contacts. The shared DATABASE_URL lives in the keychain entry named by config.json > defaults.neon_pooled_url_keychain.
CFG=~/social-autoposter/config.json
SHARED_DB_URL=$(security find-generic-password -l "$(jq -r '.defaults.neon_pooled_url_keychain' "$CFG")" -w)
# Run DDL on the shared DB, table names prefixed with the new client's slug.
psql "$SHARED_DB_URL" -v ON_ERROR_STOP=1 <<SQL
CREATE TABLE IF NOT EXISTS <slug>_emails (
id SERIAL PRIMARY KEY,
resend_id TEXT,
direction TEXT NOT NULL DEFAULT 'outbound',
from_email TEXT,
to_email TEXT,
subject TEXT,
body_text TEXT,
body_html TEXT,
status TEXT DEFAULT 'sent',
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_<slug>_emails_created_at ON <slug>_emails(created_at DESC);
CREATE TABLE IF NOT EXISTS <slug>_contacts (
id SERIAL PRIMARY KEY,
email TEXT NOT NULL,
name TEXT,
message TEXT,
resend_ok BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_<slug>_contacts_created_at ON <slug>_contacts(created_at DESC);
SQL
The new client's Cloud Run service gets DATABASE_URL=<same shared pooled URL>. No new Neon project, no new keychain entry.
Extending the contact schema: if the site's contact form collects extra fields (outcome, timeline, budget, role, etc.), add them as nullable columns to <slug>_contactsand extend the /api/contact INSERT.
Rare exception: dedicated project. Only create a new Neon project if the client has a meaningful data-isolation requirement (regulated industry, customer-controlled data, very high write volume). For typical marketing-site usage (newsletter + contact form), the shared DB is correct.
Resend — reuse the shared full-access API key, create only a new audience per client.
Every client site uses the same runtime RESEND_API_KEY from the keychain entry named by config.json > defaults.resend_master_key_keychain (master full-access key). The only per-client resource is the audience (so newsletter subscribers from different sites don't mix).
Domain verification is REQUIRED for every new client site. No exceptions, no escape hatch. Every client site ships with a branded fromEmail on the client's own domain (e.g. matt@<DOMAIN>), so the client domain must be added to Resend and DNS-verified before any newsletter send will work. Skipping this produces a silent failure: Resend upserts the contact to the audience and the route returns {success:true}, but the welcome email send 403s (only console-logged). The subscriber never gets mail.
CFG=~/social-autoposter/config.json
RESEND_KEY=$(security find-generic-password -l "$(jq -r '.defaults.resend_master_key_keychain' "$CFG")" -w)
# 1. Create the per-client audience (the only required call).
curl -s -X POST -H "Authorization: Bearer $RESEND_KEY" -H "Content-Type: application/json" \
-d '{"name":"<SLUG> newsletter"}' \
"https://api.resend.com/audiences"# → {"id":"<audience-uuid>","name":"..."}# 2. Save the audience UUID back to keychain.
security add-generic-password -U -a "you@example.com" -s "resend-<SLUG>-audience-id" -w "<audience-uuid>"
The new client's Cloud Run service gets RESEND_API_KEY=<master key> + RESEND_AUDIENCE_ID=<new-audience-uuid>. No new per-client API key is created.
Why shared key is safe here: the master full-access key was already scoped to the account; adding another client site does not widen its blast radius (anyone with the key could already send from any domain and mutate any audience). The audience UUID is the actual isolation boundary between clients.
Branded from-address (default): add the domain + DNS records. This is required unless you've overridden fromEmail to a pre-verified shared domain.
# Add the client domain
curl -s -X POST -H "Authorization: Bearer $RESEND_KEY" -H "Content-Type: application/json" \
-d '{"name":"<DOMAIN>","region":"us-east-1"}' \
"https://api.resend.com/domains" > /tmp/resend_domain.json
cat /tmp/resend_domain.json | python3 -m json.tool
# Copy the DKIM/SPF/MX records into the client's Cloud DNS zone in Phase 6e.# After DNS is live, trigger verification (propagation is usually seconds on Cloud DNS):
curl -s -X POST -H "Authorization: Bearer $RESEND_KEY" \
"https://api.resend.com/domains/<DOMAIN_ID>/verify"# Poll until status=verified before you consider Phase 3.5 done:
curl -s -H "Authorization: Bearer $RESEND_KEY" \
"https://api.resend.com/domains/<DOMAIN_ID>" \
| python3 -c "import json,sys;print(json.load(sys.stdin)['status'])"
Exit criterion:POST /api/newsletter with a real email returns 2xx AND a row with last_event: delivered shows up in GET https://api.resend.com/emails?limit=3. A {success:true} response alone is NOT sufficient — the handler returns success even when the send 403s.
There is no per-client API key (no resend-<slug>-api-key entry). The master key and the audience UUID are the only credentials the Cloud Run service needs.
Cal.com[opt-in: book-a-call] — skip this entire Cal.com block unless Book-a-Call was requested. One team event type per client under the shared Cal team (read cal_team_id and cal_team_slug from config.json > defaults). Clients inherit the team's webhook registration (Phase 6g), so a single webhook covers every client. The per-client event type URL becomes https://cal.com/team/<cal_team_slug>/<slug>, which is the value you put in src/components/book-call-button.tsx as BOOKING_URL.
CAL_KEY=$(security find-generic-password -l "$(jq -r '.defaults.cal_api_key_keychain' ~/social-autoposter/config.json)" -w)
TEAM_ID=$(jq -r '.defaults.cal_team_id' ~/social-autoposter/config.json)
TEAM_SLUG=$(jq -r '.defaults.cal_team_slug' ~/social-autoposter/config.json)
# 1. Check that the slug is not already taken (including hidden events)
curl -s -H "Authorization: Bearer $CAL_KEY" -H "cal-api-version: 2024-06-14" \
"https://api.cal.com/v2/teams/$TEAM_ID/event-types" \
| python3 -c "import json,sys; [print(e['slug']) for e in json.load(sys.stdin).get('data', [])]"# 2. Create the per-client event (30m COLLECTIVE — matches mediar-next-day template)
curl -s -X POST "https://api.cal.com/v2/teams/$TEAM_ID/event-types" \
-H "Authorization: Bearer $CAL_KEY" \
-H "cal-api-version: 2024-06-14" \
-H "Content-Type: application/json" \
-d '{
"title": "<Brand> Intro Call",
"slug": "<SLUG>",
"lengthInMinutes": 30,
"description": "30-minute intro call to discuss <one-line brand pitch>.",
"schedulingType": "COLLECTIVE"
}' | python3 -m json.tool
# → {"status":"success","data":{"id":<event-id>,"slug":"<SLUG>","hosts":[...], ...}}# 3. CRITICAL: verify the event has at least one host. A COLLECTIVE event created via# the API can come back with `hosts: []` if the caller is not a team owner/admin for# the target team, which surfaces to visitors as "no availability" on the embed with# no error. Read the freshly-created event back and assert a non-empty hosts array:
EVENT_ID=<id-from-step-2>
HOSTS=$(curl -s -H "Authorization: Bearer $CAL_KEY" -H "cal-api-version: 2024-06-14" \
"https://api.cal.com/v2/teams/$TEAM_ID/event-types/$EVENT_ID" \
| python3 -c "import json,sys;print(len(json.load(sys.stdin)['data'].get('hosts',[])))")
if [ "$HOSTS" -eq 0 ]; then# Attach a team member as host. The default is the team owner, stored once as# `defaults.cal_default_host_user_id` in ~/social-autoposter/config.json.# If the client has their own Cal account and should own the calendar (common# for named-engineer sites), invite them to the team first via the Cal dashboard,# then pass their userId here instead of the default.
HOST_USER_ID=$(jq -r '.defaults.cal_default_host_user_id' ~/social-autoposter/config.json)
curl -s -X PATCH "https://api.cal.com/v2/teams/$TEAM_ID/event-types/$EVENT_ID" \
-H "Authorization: Bearer $CAL_KEY" -H "cal-api-version: 2024-06-14" \
-H "Content-Type: application/json" \
-d "{\"hosts\":[{\"userId\":$HOST_USER_ID,\"mandatory\":false,\"priority\":\"medium\"}]}"fi
Collision gotcha: the slug space is global across hidden + visible team events. If create returns "Team event type with this slug already exists", use a short alias that matches the domain (we used cl0ne for cl0ne.ai because clone was taken by a legacy hidden event). Update src/components/book-call-button.tsx with whatever slug actually got created.
Empty-hosts gotcha (observed 2026-04-20): the create call succeeded, the event appeared at cal.com/team/<TEAM_SLUG>/<SLUG>, but the embed showed a blank calendar because hosts: [] meant nobody was bookable. The step-3 verification + PATCH above is mandatory. Exit criterion for the Cal block: GET /v2/teams/$TEAM_ID/event-types/$EVENT_ID returns hosts with at least one entry, AND opening https://cal.com/team/<TEAM_SLUG>/<SLUG> shows available time slots.
Cloudflare caveat: all Cal.com API calls must go through curl (or a tool with a matching TLS fingerprint). Python urllib gets 403/1010 Cloudflare challenges. Same applies to the Resend create-domain call above.
3.5m. Env var inventory (set during Phase 6 deploy)
Var
Build-time / Runtime
Source (shared or per-client)
Where it goes
NEXT_PUBLIC_POSTHOG_KEY
Build-arg (Docker)
Shared — phc_... from keychain entry config.json > defaults.posthog_phc_token_keychain
Baked into client bundle
NEXT_PUBLIC_POSTHOG_HOST
Build-arg (Docker)
Shared — config.json > defaults.posthog_host
Baked into client bundle
RESEND_API_KEY
Runtime
Shared — master key from keychain entry config.json > defaults.resend_master_key_keychain
Cloud Run --set-env-vars
RESEND_AUDIENCE_ID
Runtime
Per-client — UUID from resend-<slug>-audience-id keychain entry
Cloud Run --set-env-vars
DATABASE_URL
Runtime
Shared — pooled URL from keychain entry config.json > defaults.neon_pooled_url_keychain
Cloud Run --set-env-vars
GEMINI_API_KEY
Runtime
Shared (keychain entry gemini-api-key, Google AI Studio)
Cloud Run --set-env-vars. Required whenever GuideChatPanel or /api/guide-chat ships. When missing, the panel's health probe returns 503 and the component hides itself, so the site still renders without the page assistant.
Dockerfile must declare the three NEXT_PUBLIC_* vars as ARG and ENV in the builder stage so next build bakes them in. Runtime-only secrets go on the Cloud Run service, NOT the Dockerfile.
CRITICAL — strip trailing \n from every env var before setting. Follow the global rule in ~/.claude/CLAUDE.md (use echo -n and verify with gcloud run services describe). A single \n on RESEND_API_KEY will break signing and produce silent 401s.
3.5n. Exit criteria
NewsletterSignup renders in dev; submitting a valid email to the deployed endpoint with a real address you control results in an actual inbox delivery. A {success:true} response alone does NOT pass this criterion — the handler returns 2xx even when Resend 403s the welcome send due to an unverified domain. Confirm by (a) receiving the email in your inbox, and (b) curl -H "Authorization: Bearer $RESEND_KEY" "https://api.resend.com/emails?limit=1" showing last_event: delivered with from: <your client-domain from-address>.
NewsletterSignup Subscribe button has a teal (bg-teal-500) background, NOT transparent. Scroll past 600px to reveal the bar, then in DevTools run getComputedStyle(document.querySelector('button[type=submit]')).backgroundColor. If it returns rgba(0, 0, 0, 0), the @source "../../node_modules/@seo/components/src"; directive is missing from globals.css — utilities used inside packaged components got overridden by Tailwind's base preflight. Fix in 2c before continuing.
/api/newsletter POST (curl against local dev with real Resend creds) adds the contact to Resend audience AND writes a row to <slug>_emails
/api/contact POST lands a notification email in you@example.com AND writes a row to <slug>_contacts
window.posthog is non-undefined in the browser console on / (type window.posthog in DevTools — must return an object, not undefined; see 3.5c-verify)
Clicking a TrackedCta fires a cta_clicked event visible in PostHog Live events
Submitting the NewsletterSignup fires a newsletter_subscribed event visible in PostHog Live events (confirms @m13v/seo-components library analytics path works end-to-end)
No trailing \n in any env var (verify with gcloud run services describe ... --format='value(spec.template.spec.containers[0].env)')
Phase 4: SEO Guide Pages Infrastructure
Scope of this phase: wire the infrastructure so the gsc-seo-page skill and ~/social-autoposter/seo/generate_page.py can land /t/<slug> pages. Do NOT write guide pages here. Every guide page is produced by the generator, which reads the authoritative component registry from @m13v/seo-components and handles SERP research, theme detection, and content writing.
4a. Configure next.config for guide discovery
Wrap the Next.js config with withSeoContent to enable build-time guide discovery:
At next build, this walks the content directory and generates .next/seo-guides-manifest.json so runtime code (sidebar, chat API) can read the page inventory without filesystem access on Cloud Run.
contentDir selection rule:
If the site has /t/<slug> guide pages (generated by ~/social-autoposter/seo/generate_page.py), point contentDir at the guide folder: src/app/(main)/t, src/app/t, or wherever the generator writes.
If the site has NO /t/* guides (marketing-only sites like c0nsl.com with top-level /services, /about, /faq, /portfolio, /book), point contentDir at the app root: src/app. The walker will pick up every top-level route's page.tsx as a discoverable page, and the chat API will be able to answer questions about them. If you leave it pointed at src/app/t on a site that has no t/ folder, the manifest is empty, /api/guide-chat exhausts its tool rounds looking for pages it never finds, and the page assistant returns max_tool_rounds_exceeded.
Whatever value you pick here must match contentDir in src/app/api/guide-chat/route.ts (Phase 4e) exactly; they read the same manifest.
4b. Create guide index at src/app/(main)/t/page.tsx
This is the only /t/* file this skill creates. All /t/<slug>/page.tsx files are produced by the generator below.
4c. Register the site and hand off to the generator
Once 4a and 4b are in place, this skill's responsibility for SEO pages ends. For every guide page:
Register the new site in ~/social-autoposter/config.json under projects.<slug> with its repo path, production domain, and /t route. The SEO pipelines read this file to know which sites exist.
Run the generator for the first page to verify the setup end to end:
From here on, use the gsc-seo-page skill for any single-page generation, or let the cron pipelines (seo/run_gsc_pipeline.sh, seo/run_serp_pipeline.sh) drive it automatically.
The generator reads @m13v/seo-components/registry.json from the target repo's node_modules, so the component palette stays in sync with the installed version of the library. Do not hardcode a list of components in this skill or in any consumer repo.
4d. Sidebar navigation — already mounted in Phase 2d
SitemapSidebar is mounted in the root src/app/layout.tsx per Phase 2d. Do not wrap it in a client component or a bespoke site-sidebar.tsx — the library handles the "use client" boundary internally. The only per-site customization is the brandName prop and the hideOnPaths array (default: ["/"] to hide on the homepage). walkPages() from @seo/components/server is called directly in the root layout (server component) and the resulting tree is passed as pages.
4e. Add AI guide chat API route
Create src/app/api/guide-chat/route.ts:
import { createGuideChatHandler } from"@seo/components/server";
exportconst runtime = "nodejs";
exportconst dynamic = "force-dynamic";
exportconstPOST = createGuideChatHandler({
app: "CLIENT_SLUG",
brand: "CLIENT_NAME",
siteDescription: "Brief description of the client's business.",
// MUST match the value passed to withSeoContent in next.config.ts (see 4a).// Use "src/app" for marketing-only sites with no /t/* guides; use the guide// folder (e.g. "src/app/(main)/t") once the SEO generator starts writing pages.contentDir: "src/app/(main)/t",
});
GuideChatPanel is already mounted in the root src/app/layout.tsx per Phase 2d (inside the three-column flex row). Do NOT add a second <GuideChatPanel /> inside guide pages — that will render two asides side-by-side. The only remaining task here is creating the API route above so the panel's boot-time health probe returns 200.
Runtime requirements:
GEMINI_API_KEY must be set on Cloud Run (see Phase 6d env var table). Without it the health probe returns 503, the panel hides itself, and the rest of the site still renders.
Version @m13v/seo-components >= 0.19.1 is required for the self-hide behavior. Older versions render an empty, non-functional panel when the key is missing.
Verify in production after deploy:
curl -sS -X POST https://DOMAIN/api/guide-chat \
-H 'content-type: application/json' -d '{"healthCheck":true}' \
-w '\nHTTP %{http_code}\n'# Expect: {"ok":true} / HTTP 200. Then load any page at ≥1280px viewport and# confirm the assistant rail renders on the right.
4f. Component inventory
The authoritative list of @seo/components exports is ~/seo-components/registry.json (emitted at build) and ~/seo-components/src/index.ts. Do not maintain a duplicate list in this skill or in consumer repos. The generator reads the registry directly when it writes a page.
Phase 5: Build and Verify
5a. Build
cd ~/CLIENT-website && npm run build
Fix any TypeScript or build errors. All routes must compile and generate successfully.
5b. Visual Comparison
Use the isolated browser to take full-page screenshots of the new site and compare side-by-side with the originals from Phase 1e.
Check for:
Real logo (not text placeholder)
All client/team photos present
Video embeds working (not placeholder cards)
Scheduling widget embedded (not placeholder)
Book/product images displayed
Social media icons in footer
Navigation matches original site structure
Color scheme matches client brand
Mobile responsive (test at 375px width)
5b.5. Design Review (gate before deploy)
The new site must pass an independent design review before Phase 6 (deploy). Structure check is not enough; the site must feel like the design thesis promised, not like yet another client template.
Run:
# Take full-page screenshots of the live local buildcd ~/CLIENT-website && npm run dev &
# use isolated-browser to screenshot at /, /how-it-works, /precall (or the /install route if product-led), /faq, /t# save to review/screenshots/
Then spawn the design-review skill (or design-shotgun for a broader critique) with the screenshots AND these three artifacts attached: research/visual-references.md, research/design-brief.md, and exemplars/claude-meter-editorial.html. The prompt:
Review the attached new-client-site screenshots against the design brief and visual references. Does the site deliver the thesis? Does it use the signature motifs? Does it avoid every pattern listed under "what this site will NOT do"? Flag any section that reads as generic corporate template. Identify which reference the site is visually closest to, and whether that's the intended thesis. Output: pass / fail with 3-5 specific callouts.
Fail conditions (auto-reject):
Any section renders as rounded-xl shadow cards when the brief called for hairline grid (or vice versa).
The homepage hero has no animated or interactive moment when the brief promised one.
Two or more sections use the same visual pattern as the Corporate B2B Safe Baseline (Appendix A) without intentional reason.
The site is visually indistinguishable from any existing config.json project (spot-check Mediar, Fazm, Assrt, Cyrano, FDE10X, Claude Meter).
On fail: return to Phase 3, rework the failed sections against the brief. Do not deploy a site that fails this gate just because the build passes.
5c. Analytics wiring audit
After the config.json entry lands (Phase 10), run the wiring checker to confirm PostHog + @m13v/seo-components are wired correctly on every consumer site, including this new one. It catches the silent-failure bug class where window.posthog is never set and helpers like NewsletterSignup / trackScheduleClick no-op.
cd ~/social-autoposter && python3 scripts/check_analytics_wiring.py
Exits 0 if all sites pass, 1 on any BROKEN project.
Preferred fix: mount <FullSiteAnalytics> from @m13v/seo-components (handles PostHog init + window.posthog mirror + <SeoAnalyticsProvider> in one component).
If the new site is BROKEN, step through 3.5b and 3.5c again before shipping.
FROM node:20-alpine AS base
# --- Dependencies ---
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci --ignore-scripts
# --- Builder ---
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# NEXT_PUBLIC_* vars must be present at build time because Next.js bakes them
# into the client bundle. Runtime env vars on Cloud Run are too late.
#
# Do NOT declare `ARG NEXT_PUBLIC_* / ENV NEXT_PUBLIC_*=$ARG` here. If the ARG
# is not passed at `docker build` time (and `gcloud run deploy --source .`
# does NOT pass `--set-build-env-vars` through to Dockerfile ARGs — that flag
# is buildpacks-only), the ENV ends up as an empty string, and Next.js's env
# load order puts process.env ABOVE .env.production, so the committed key is
# silently stomped. The client bundle then ships with the variable undefined,
# dead-code-eliminates the init call, and the site is dark in PostHog.
# (This footgun bit fde10x-website on 2026-04-19.)
#
# Instead, commit a `.env.production` file at repo root (see 6c.env-production
# below for the .gitignore exception). Next.js loads it automatically during
# `npm run build` inside this image. That pattern works for the full set of
# NEXT_PUBLIC_* values we care about here (all public — phc_ PostHog keys,
# site id slugs, public API hosts). Real secrets never live in NEXT_PUBLIC_*
# at all; they go through runtime env in 6d.
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
# --- Runner ---
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
ENV PORT=8080
ENV HOSTNAME="0.0.0.0"
EXPOSE 8080
CMD ["node", "server.js"]
6c.env-production. Commit .env.production with the public client-side vars
Allow the file into git with a targeted .gitignore exception:
# ~/.claude/skills/setup-client-website/templates/.gitignore append:
# env files (can opt-in for committing if needed)
.env*
# NEXT_PUBLIC_POSTHOG_KEY is a phc_ ingestion key: public by design,
# inlined into the client bundle anyway. Committing it lets the Docker
# build pick it up without --build-arg plumbing.
!.env.production
Contents of .env.production (single source of truth for build-time NEXT_PUBLIC_* values):
MANDATORY substitution — do NOT commit placeholders. Before writing .env.production, pull real values:
CFG=~/social-autoposter/config.json
PHC_KC=$(jq -r '.defaults.posthog_phc_token_keychain'"$CFG")
PH_HOST=$(jq -r '.defaults.posthog_host'"$CFG")
PHC=$(security find-generic-password -l "$PHC_KC" -w)
test -n "$PHC" && grep -q "^phc_" <<< "$PHC" || { echo"phc_ key not found in keychain ($PHC_KC)"; exit 1; }
printf'NEXT_PUBLIC_POSTHOG_KEY=%s\nNEXT_PUBLIC_POSTHOG_HOST=%s\n'"$PHC""$PH_HOST" > .env.production
Literal strings like phc_REPLACE_ME_... in .env.production will bake into the client bundle and silently 404 every PostHog event. If the keychain entry is missing or empty, STOP — do not deploy.
Security rule: only values that are safe in a public git repo go here. phc_ PostHog ingestion keys are by design publicly embedded in the client bundle — an attacker can scrape them off the deployed site anyway. A phx_ personal API key, a re_ Resend API key, or any database URL is NOT in this file. Those live in Cloud Run runtime env only (see 6d).
Cross-site segmentation on the shared PostHog project is driven purely by the browser-provided properties.$host; no per-site env var is needed.
6d. Deploy Cloud Run service
# Create GCP project (or reuse existing)
gcloud services enable run.googleapis.com dns.googleapis.com \
compute.googleapis.com certificatemanager.googleapis.com \
--project=GCP_PROJECT_ID
# Deploy (source-based: Cloud Build builds the Dockerfile automatically).# Use --set-env-vars (NOT --update-env-vars) so deletions also take effect.# CRITICAL: use --set-env-vars "KEY=value" with NO trailing newlines; every global env# var rule from ~/.claude/CLAUDE.md applies here.## Do NOT pass --set-build-env-vars for NEXT_PUBLIC_*. That flag is buildpacks-only# and does NOT propagate to Dockerfile ARGs. NEXT_PUBLIC_* values come from the# committed .env.production file (see 6c.env-production). This flag path was# the root cause of the 2026-04-19 fde10x-website posthog-dark incident.## --set-env-vars is for RUNTIME secrets: they never need to be baked into the# client bundle. Anything the server reads via process.env.X at request time# (database URL, Resend API key, anything server-side) goes here.
gcloud run deploy SERVICE_NAME \
--source . \
--region us-central1 \
--project GCP_PROJECT_ID \
--allow-unauthenticated \
--set-env-vars "^|^RESEND_API_KEY=re_...|RESEND_AUDIENCE_ID=...|DATABASE_URL=postgresql://...|GEMINI_API_KEY=..." \
--quiet
# Verify runtime env vars have no trailing \n
gcloud run services describe SERVICE_NAME --project=GCP_PROJECT_ID --region=us-central1 \
--format="value(spec.template.spec.containers[0].env)" 2>&1 | tr';''\n' | grep -E "RESEND|DATABASE|GEMINI"# Verify the page assistant is healthy in production (requires GEMINI_API_KEY set above):
curl -sS -X POST https://DOMAIN/api/guide-chat \
-H 'content-type: application/json' -d '{"healthCheck":true}' -w '\nHTTP %{http_code}\n'# Expect: {"ok":true} / HTTP 200. If 503 {"ok":false,"reason":"missing_gemini_key"},# re-set GEMINI_API_KEY with no trailing \n and redeploy; the component will self-hide# until this returns 200.# Verify NEXT_PUBLIC_POSTHOG_KEY actually got baked into the client bundle# (not undefined, dead-code-eliminated away):
curl -sS https://DOMAIN/ -o /tmp/home.html
grep -oE '/_next/static/chunks/[^"\\]+\.js' /tmp/home.html | sort -u | while IFS= read -r url; doif curl -sS "https://DOMAIN$url" | grep -qE 'phc_[A-Za-z0-9]{20,}'; thenecho"OK: phc_ baked in $url"; breakfidone
6e. Set up HTTPS Load Balancer with custom domain
# Reserve static IP
gcloud compute addresses create PROJECT_NAME-ip --global --project=GCP_PROJECT_ID
STATIC_IP=$(gcloud compute addresses describe PROJECT_NAME-ip --global --project=GCP_PROJECT_ID --format="value(address)")
# Create Cloud DNS zone and A record
gcloud dns managed-zones create DNS_ZONE --dns-name="DOMAIN." \
--description="DNS zone for DOMAIN" --project=GCP_PROJECT_ID
gcloud dns record-sets create DOMAIN. --type=A --ttl=300 \
--rrdatas="$STATIC_IP" --zone=DNS_ZONE --project=GCP_PROJECT_ID
# Certificate Manager DNS authorization + managed cert
gcloud certificate-manager dns-authorizations create PROJECT_NAME-dns-auth \
--domain="DOMAIN" --project=GCP_PROJECT_ID
AUTH_CNAME=$(gcloud certificate-manager dns-authorizations describe PROJECT_NAME-dns-auth \
--project=GCP_PROJECT_ID --format="value(dnsResourceRecord.name)")
AUTH_DATA=$(gcloud certificate-manager dns-authorizations describe PROJECT_NAME-dns-auth \
--project=GCP_PROJECT_ID --format="value(dnsResourceRecord.data)")
gcloud dns record-sets create "${AUTH_CNAME}." --type=CNAME --ttl=300 \
--rrdatas="${AUTH_DATA}." --zone=DNS_ZONE --project=GCP_PROJECT_ID
gcloud certificate-manager certificates create PROJECT_NAME-cert \
--domains="DOMAIN" --dns-authorizations=PROJECT_NAME-dns-auth --project=GCP_PROJECT_ID
gcloud certificate-manager maps create PROJECT_NAME-cert-map --project=GCP_PROJECT_ID
gcloud certificate-manager maps entries create PROJECT_NAME-cert-entry \
--map=PROJECT_NAME-cert-map --certificates=PROJECT_NAME-cert \
--hostname="DOMAIN" --project=GCP_PROJECT_ID
# Serverless NEG, backend, URL map, HTTPS proxy, forwarding rule
gcloud compute network-endpoint-groups create PROJECT_NAME-neg \
--region=us-central1 --network-endpoint-type=serverless \
--cloud-run-service=SERVICE_NAME --project=GCP_PROJECT_ID
gcloud compute backend-services create PROJECT_NAME-backend --global --project=GCP_PROJECT_ID
gcloud compute backend-services add-backend PROJECT_NAME-backend --global \
--network-endpoint-group=PROJECT_NAME-neg \
--network-endpoint-group-region=us-central1 --project=GCP_PROJECT_ID
gcloud compute url-maps create PROJECT_NAME-urlmap \
--default-service=PROJECT_NAME-backend --global --project=GCP_PROJECT_ID
gcloud compute target-https-proxies create PROJECT_NAME-https-proxy \
--url-map=PROJECT_NAME-urlmap --certificate-map=PROJECT_NAME-cert-map \
--global --project=GCP_PROJECT_ID
gcloud compute forwarding-rules create PROJECT_NAME-https-rule --global \
--target-https-proxy=PROJECT_NAME-https-proxy --address=PROJECT_NAME-ip \
--ports=443 --project=GCP_PROJECT_ID
# HTTP to HTTPS redirect
gcloud compute url-maps import PROJECT_NAME-http-redirect --global --project=GCP_PROJECT_ID <<EOF
name: PROJECT_NAME-http-redirect
defaultUrlRedirect:
httpsRedirect: true
redirectResponseCode: MOVED_PERMANENTLY_DEFAULT
EOF
gcloud compute target-http-proxies create PROJECT_NAME-http-proxy \
--url-map=PROJECT_NAME-http-redirect --global --project=GCP_PROJECT_ID
gcloud compute forwarding-rules create PROJECT_NAME-http-rule --global \
--target-http-proxy=PROJECT_NAME-http-proxy --address=PROJECT_NAME-ip \
--ports=80 --project=GCP_PROJECT_ID
# Lock down Cloud Run to LB-only traffic
gcloud run services update SERVICE_NAME \
--ingress=internal-and-cloud-load-balancing \
--region=us-central1 --project=GCP_PROJECT_ID
# Resend domain DNS records (from Phase 3.5 external-service provisioning).# Without these, the welcome email + inbound webhook won't work.# Values come from Resend's "Add domain" dialog; paste them in.
gcloud dns record-sets create "send.DOMAIN." --type=TXT --ttl=300 \
--rrdatas='"v=spf1 include:amazonses.com ~all"' \
--zone=DNS_ZONE --project=GCP_PROJECT_ID
gcloud dns record-sets create "resend._domainkey.DOMAIN." --type=TXT --ttl=300 \
--rrdatas='"<DKIM value from Resend, usually 2 quoted chunks>"' \
--zone=DNS_ZONE --project=GCP_PROJECT_ID
gcloud dns record-sets create "send.DOMAIN." --type=MX --ttl=300 \
--rrdatas="10 feedback-smtp.us-east-1.amazonses.com." \
--zone=DNS_ZONE --project=GCP_PROJECT_ID
# INBOUND MX on the apex (MANDATORY, see Phase 3.5k). Lands replies at Resend.# Resend Inbound is built on AWS SES; the MX target is the SES inbound endpoint.
gcloud dns record-sets create "DOMAIN." --type=MX --ttl=300 \
--rrdatas="10 inbound-smtp.us-east-1.amazonaws.com." \
--zone=DNS_ZONE --project=GCP_PROJECT_ID
# Trigger Resend re-verification after these propagate (~5-30s on Cloud DNS):
RESEND_KEY=$(security find-generic-password -l "$(jq -r '.defaults.resend_master_key_keychain' ~/social-autoposter/config.json)" -w)
curl -s -X POST -H "Authorization: Bearer $RESEND_KEY" \
"https://api.resend.com/domains/<DOMAIN_ID>/verify"
6f. Create GitHub Actions workflow for CI/CD (WIF, not SA keys)
Use Workload Identity Federation for CI/CD auth. Some GCP org policies (e.g. iam.disableServiceAccountKeyCreation) block service account JSON key creation outright, and WIF is the portable pattern that works whether or not the policy is enforced.
Pre-step: create a per-client WIF pool + provider in the new GCP project, then bind the deploy SA to the repo:
PROJECT="<slug>-prod"
REPO="$(jq -r '.defaults.github_owner' ~/social-autoposter/config.json)/<STEM>-website"
DEPLOY_SA="<STEM>-deployer@${PROJECT}.iam.gserviceaccount.com"# Create the deploy SA
gcloud iam service-accounts create <STEM>-deployer \
--project="${PROJECT}" \
--display-name="<STEM> Cloud Run deployer"# Grant the roles it needsfor role in roles/run.admin roles/cloudbuild.builds.editor roles/storage.admin roles/iam.serviceAccountUser; do
gcloud projects add-iam-policy-binding "${PROJECT}" \
--member="serviceAccount:${DEPLOY_SA}" \
--role="${role}"done# Create WIF pool + GitHub OIDC provider (one-time per project)
gcloud iam workload-identity-pools create github-pool \
--location=global --project="${PROJECT}" --display-name="GitHub Actions"
gcloud iam workload-identity-pools providers create-oidc github-provider \
--location=global --workload-identity-pool=github-pool --project="${PROJECT}" \
--issuer-uri="https://token.actions.githubusercontent.com" \
--attribute-mapping="google.subject=assertion.sub,attribute.repository=assertion.repository" \
--attribute-condition="assertion.repository=='${REPO}'"# Bind the SA to the repo
PROJECT_NUMBER=$(gcloud projects describe "${PROJECT}" --format='value(projectNumber)')
gcloud iam service-accounts add-iam-policy-binding "${DEPLOY_SA}" \
--project="${PROJECT}" \
--role="roles/iam.workloadIdentityUser" \
--member="principalSet://iam.googleapis.com/projects/${PROJECT_NUMBER}/locations/global/workloadIdentityPools/github-pool/attribute.repository/${REPO}"
Per-project WIF is the new default. Older client sites shared a single WIF pool across many repos, which required an attributeCondition listing every allowed repo (one fde10x deploy failed on 2026-04-17 because the condition didn't list the new repo yet). A pool scoped to <slug>-prod with a condition of exactly assertion.repository=='<owner>/<STEM>-website' avoids the cross-repo coupling entirely.
Create .github/workflows/deploy-cloudrun.yml:
name:DeploytoCloudRunon:push:branches: [main]
workflow_dispatch:env:REGION:us-central1PROJECT_ID:<slug>-prodSERVICE_ACCOUNT:<STEM>-deployer@<slug>-prod.iam.gserviceaccount.comWIF_PROVIDER:projects/${{secrets.GCP_PROJECT_NUMBER}}/locations/global/workloadIdentityPools/github-pool/providers/github-providerjobs:deploy:runs-on:ubuntu-latesttimeout-minutes:20permissions:contents:readid-token:write# REQUIRED for WIFconcurrency:group:deploy-productioncancel-in-progress:falsesteps:-uses:actions/checkout@v4-id:authuses:google-github-actions/auth@v2with:workload_identity_provider:${{env.WIF_PROVIDER}}service_account:${{env.SERVICE_ACCOUNT}}-uses:google-github-actions/setup-gcloud@v2-name:DeploytoCloudRunrun:|
gcloud run deploy <STEM>-website \
--source . \
--region ${{ env.REGION }} \
--project ${{ env.PROJECT_ID }} \
--quiet
-name:Verifydeploymentrun:|
PUBLIC=$(curl -s -o /dev/null -w "%{http_code}" --max-time 30 "https://DOMAIN/" || echo "000")
echo "Public health (https://DOMAIN/): HTTP $PUBLIC"
if [ "$PUBLIC" != "200" ]; then
echo "::warning::Public health check returned $PUBLIC"
fi
No GitHub repo secrets are needed for auth. The id-token: write permission + WIF provider identity handle it. Do NOT create GCP_SA_KEY or credentials_json secrets. If the org policy is ever lifted for a new project, that alternative path is out of scope for this skill.
6g. Wire Cal.com bookings into the central stats pipeline [opt-in: book-a-call]
Skip this entire phase unless Book-a-Call was requested. Sites without a Book CTA have nothing to wire: no event type exists (3.5l was skipped), no BookCallLink exists (3.5d was skipped), no client_slug entry is needed in the webhook or project_stats.py.
Three places to touch for a new client. Skip any that already match.
1) Add the client slug's keyword to the webhook's client_slug detection. Edit ~/social-autoposter-website/src/app/api/webhooks/cal/route.ts. The detection block is an ordered if/else if. Add the new client above any branch whose keyword might overlap (e.g. a sub-brand's team calls often inherit the parent team's name in the title, so the sub-brand branch must precede the parent branch):
} elseif (
eventSlug.includes("<SLUG>") ||
title.includes("<SLUG>") ||
// add brand aliases here if the title/slug use them (e.g. "pias" for fde10x)
) {
clientSlug = "<SLUG>";
}
Commit, push, and confirm the preview alias at https://social-autoposter-website.vercel.app/api/webhooks/cal returns 200 on GET.
2) Register the webhook at the cal.com team level (NOT on the personal account — every event type in the team, including this client's, will inherit the team webhook). Look up the team and URL from config: TEAM=$(jq -r '.defaults.cal_team_slug' ~/social-autoposter/config.json) and URL=$(jq -r '.defaults.cal_webhook_url' ~/social-autoposter/config.json). In the browser, https://app.cal.com/settings/developer/webhooks → New → pick that team → Subscriber URL $URL → leave all default event triggers checked (BOOKING_CREATED, BOOKING_CANCELLED, BOOKING_RESCHEDULED, BOOKING_REJECTED, BOOKING_REQUESTED, plus the rest) → Enabled → Save. A secret is optional; the current handler does not verify signatures.
3) Add "<SLUG>": "<SLUG>" to get_client_slug() in ~/social-autoposter/scripts/project_stats.py:
defget_client_slug(project_name):
"""Map project name to cal_bookings client_slug."""return {"Cyrano": "cyrano", "PieLine": "pieline", "fazm": "fazm", "S4L": "s4l",
"<SLUG>": "<SLUG>"}.get(project_name)
This file is locked with chflags uchg — temporarily unlock with chflags nouchg, edit, re-lock with chflags uchg.
Verification (one real booking then cancel):
# After the webhook is registered, book any slot on https://cal.com/team/<TEAM>/<SLUG># Then check the Neon cal_bookings row landed with client_slug=<SLUG>:export BOOKINGS_DATABASE_URL=$(grep '^BOOKINGS_DATABASE_URL=' ~/social-autoposter/.env | cut -d= -f2-)
python3 - <<PY
import re, psycopg2
conn = psycopg2.connect("$BOOKINGS_DATABASE_URL"); cur = conn.cursor()
cur.execute("SELECT cal_booking_id, client_slug, status FROM cal_bookings ORDER BY created_at DESC LIMIT 3")
for r in cur.fetchall(): print(r)
PY# Should include one row with client_slug='<SLUG>'. Cancel the booking when done.
Phase 7: Register for Tracking
7a. Add repo to git-dashboard auto-commit pipeline
The background auto-commit agent at ~/git-dashboard/auto_commit.py inspects, commits, and pushes uncommitted changes across the user's active repos every minute (see ~/.claude/CLAUDE.md "Background Auto-Commit Agent"). New client repos are NOT auto-added — you must append the absolute path to the REPOS list once, or every subsequent change will sit uncommitted and deploys won't trigger.
7c. Google Search Console (MANDATORY, runs here, not deferred)
Run gsc-seo-page skill > "Onboarding a New Product > Step 1" now (DNS TXT verify + add property + submit sitemap, all via the SA). Do NOT skip to Phase 9 with this undone — Phase 9 assumes the property already exists, and a missing property silently lets the site ship with zero GSC data attached for weeks (studyly.io 2026-04-30). Self-check before moving on: set -a && source ~/social-autoposter/.env && set +a && python3 -c "import os; from google.oauth2 import service_account; from googleapiclient.discovery import build; sc = build('searchconsole','v1',credentials=service_account.Credentials.from_service_account_file(os.path.expanduser(os.environ['GSC_SA_KEY_PATH']), scopes=['https://www.googleapis.com/auth/webmasters.readonly'])); assert f'sc-domain:DOMAIN' in {s['siteUrl'] for s in sc.sites().list().execute().get('siteEntry',[])}, 'SA cannot see property'; assert sc.sitemaps().list(siteUrl='sc-domain:DOMAIN').execute().get('sitemap'), 'no sitemap submitted'; print('OK_GSC')" (replace DOMAIN with the bare domain twice). Browser flow forbidden unless the SA literally cannot DNS-verify (then use the google-search-console skill).
Phase 8: Final Verification Checklist
Site loads at production URL
All pages render with real content (no placeholder text)
All images load (no broken images, no "VIDEO COMING SOON" placeholders)
Logo appears in header and footer
Navigation dropdowns work on desktop and mobile
Video embeds play
Scheduling widget loads
Social media icons present in footer
JSON-LD structured data lands in served HTML on every page (not just in source). Verify: curl -s https://DOMAIN/ | grep -c 'application/ld+json' returns at least 1 on the homepage (expect 2 or more when Organization, WebPage, and SoftwareApplication all ship). A <script type="application/ld+json"> nested inside a client component or rendered in the wrong DOM position can fail to reach SSR output, leaving Google Rich Results Test reporting "no structured data" with no clue from the source code. Root cause of the mk0r 2026-04-29 incident where 5 schema types existed in source but 0 reached crawlers
XML sitemap is dynamic and returns 200 — src/app/sitemap.ts must import generateSitemap from @seo/components/server (never hand-rolled, never static). Verify: curl -s -o /dev/null -w "%{http_code}\n" https://DOMAIN/sitemap.xml prints 200, and curl -s https://DOMAIN/sitemap.xml | grep -c "<url>" returns a count equal to the number of public pages (not 0). Also verify the canonical inside the feed:curl -s https://DOMAIN/sitemap.xml | grep -oE '<loc>[^<]+' | grep -v '<loc>https://DOMAIN/' | head -1 MUST print nothing (any output means at least one entry points at the wrong canonical, usually because baseUrl in sitemap.ts was hardcoded to a defunct prelaunch domain). Root cause of Cyrano + PieLine 2026-04-29 where sitemaps returned 200 with the right URL count, but every <loc> pointed at a stale domain that 307-redirected, sending mixed-canonical signals to Google for months
HTML sitemap page at /sitemap returns 200 — src/app/sitemap/page.tsx must exist and render HtmlSitemap from @seo/components. Verify: curl -s -o /dev/null -w "%{http_code}\n" https://DOMAIN/sitemap prints 200 (NOT 404); curl -s https://DOMAIN/sitemap | grep -q "<html" && echo OK prints OK. This is a human-readable page, not the XML feed — both MUST exist
Sitemap link in Footer (under Company column) so humans can reach /sitemap without typing the URL
robots.txt returns 200 AND references the sitemap URL — src/app/robots.ts must import generateRobots from @seo/components/server. Verify: curl -s -o /dev/null -w "%{http_code}\n" https://DOMAIN/robots.txt prints 200, and curl -s https://DOMAIN/robots.txt | grep -q "Sitemap: https://DOMAIN/sitemap.xml" && echo OK prints OK. Also verify the AI crawler allowlist actually landed:curl -s https://DOMAIN/robots.txt | grep -cE '^User-agent: (GPTBot|ClaudeBot|PerplexityBot)' MUST return 3 (proves generateRobots was not overridden with aiAllowlist: [] or replaced by a hand-rolled Disallow-by-default rule). A robots.txt that returns 200 and lists the sitemap can still ship without GPTBot/ClaudeBot/PerplexityBot entries, which silently blocks AI crawlers from learning the site exists. Root cause of the s4l, cl0ne, mk0r, studyly, mediar 2026-04-29 batch fix where five live sites were missing AI bot allowlists despite passing the previous version of this check
llms.txt accessible at /llms.txt (static file at public/llms.txt, returns 200 with the curated brief; config.json's llms_txt field for this project points at ~/<repo>/public/llms.txt)
Lighthouse desktop score >= 85
Lighthouse mobile score >= 70
No console errors on any page
All internal links work (no 404s)
SEO guide pages at /t/{slug} load with components from @seo/components (Breadcrumbs, ProofBanner, InlineCta, StickyBottomCta, FaqSection)
Guide index at /t lists all discovered guides
@seo/components installed and transpilePackages + withSeoContent configured in next.config.ts
HeadingAnchors injects id attributes on H2 elements (inspect DOM)
Layout does NOT render <SeoComponentsStyles />. grep -rn SeoComponentsStyles src/app must return no matches. The library is expected to be styled via the consumer's own Tailwind (Phase 2c @source directive), not by injecting a prebuilt stylesheet. Keeping the inline injection causes the GuideChatPanel / SitemapSidebar hidden xl:flex wrappers to lose to the consumer's @layer utilities .hidden and render display:none forever.
globals.css contains @source "../../node_modules/@seo/components/src"; (grep the file — without this, Tailwind v4 does not scan packaged components and the NewsletterSignup ships with a transparent Subscribe button, black input border, and 0px horizontal input padding)
curl -sS -X POST https://<domain>/api/guide-chat -H 'content-type: application/json' -d '{"healthCheck":true}' returns 200 {"ok":true}. Other responses mean the page assistant will silently render null: 503 missing_gemini_key (env var absent on deploy target), 400 no_messages (stale @m13v/seo-components predating v0.20.1), 404 (route not deployed or no /api/guide-chat/route.ts).
Layout structural audit (BLOCKING).SitemapSidebar, GuideChatPanel, and their custom-wrapped equivalents (SiteSidebar, GuideChat) are sticky asides — they MUST sit inside a flex-row wrapper (any of <div className="flex min-h-screen">, <div className="flex flex-1 min-h-0">, etc.). The exact failure mode is the component being a direct child of <body>: in that position, sticky has no flex-row anchor, the aside's natural position is at rectTop ≈ 8000–10000px (after the full-height article), and it's invisible on normal read. Static check walks the JSX and reports any such component at body-depth 0:
python3 -c "
import re, sys
src = open('src/app/layout.tsx').read()
body_m = re.search(r'<body[^>]*>', src)
if not body_m: print('SKIP'); sys.exit(0)
i = body_m.end(); depth = 0; fails = []
while i < len(src):
m = re.search(r'<(/?)(\w+)[^>]*?(/?)>', src[i:])
if not m: break
closing, tag, self_close = m.group(1), m.group(2), m.group(3)
if tag == 'body' and closing: break
is_self_close = bool(self_close) or tag.lower() in ('br','hr','img','input','meta','link')
if tag in ('SitemapSidebar','GuideChatPanel','SiteSidebar','GuideChat') and not closing and depth == 0:
fails.append(tag)
if not closing and not is_self_close: depth += 1
elif closing: depth -= 1
i += m.end()
if fails: print(f'FAIL: direct-child-of-body: {\", \".join(fails)}'); sys.exit(1)
print('OK')"
Expected output: OK. Runtime check in headed browser at viewport width ≥1280px: document.querySelectorAll('aside').forEach(a => console.log(a.getBoundingClientRect().top)) — both top values must be 0 (or near 0). Any top value in the thousands means the aside is stranded below the article and invisible on normal read. Root cause of the 10xats.com 2026-04-24 missing-sidebar incident (and a second, still-live case on fde10x.com where <GuideChatPanel> is a body-level sibling).
Scroll past 600px on / → newsletter bar appears → Subscribe button has a visible teal fill (DevTools: getComputedStyle(document.querySelector('button[type=submit]')).backgroundColor must NOT be rgba(0, 0, 0, 0))
Layer-leak audit.globals.css must have zero non-variable selectors outside a @layer block. Run this one-liner locally — it must return no output:
python3 -c "import re,sys; css=open('src/app/globals.css').read();
# strip @layer blocks, @theme blocks, :root, @import, @source, @layer-decls, @keyframes
stripped=re.sub(r'@layer\s+[a-z,\s]+;','',css)
stripped=re.sub(r'(:root|@theme[^{]*|@layer\s+\w+|@keyframes\s+\w+)\s*\{(?:[^{}]|\{[^{}]*\})*\}','',stripped,flags=re.S)
stripped=re.sub(r'@import[^;]+;|@source[^;]+;|/\*.*?\*/','',stripped,flags=re.S)
leaks=[l.strip() for l in stripped.splitlines() if l.strip() and not l.strip().startswith('/*')]
sys.exit(1) if leaks else None
[print('LEAK:',l) for l in leaks]"
If this prints anything, every line it prints is a rule that will silently beat @layer utilities Tailwind classes. Move the rule into @layer base { ... } in globals.css before continuing. Root cause of the c0nsl.com 2026-04-21 black-on-teal Book CTA.
Primary CTA contrast audit. In headed browser on /, run for every primary CTA on the page:
For each CTA with a solid background, the color MUST contrast with the bg. Any primary button where color ≈ bg (e.g. both near-black, both near-white) is broken and must be fixed in globals.css layering before shipping.
Build generates .next/seo-guides-manifest.json with correct page count
PostHog captures pageview, cta_click, schedule_click, get_started_click, newsletter_subscribed — validate with: curl -sS https://DOMAIN/ -o /tmp/h.html && grep -oE '/_next/static/chunks/[^"\\]+\.js' /tmp/h.html | while read u; do curl -sS https://DOMAIN$u | grep -m1 -oE 'phc_[A-Za-z0-9]+' && break; done (a hit proves the phc_ is baked into the client bundle). Only assert the event types that are in scope for this site (e.g. skip schedule_click if book-a-call is off; skip get_started_click if get-started is off).
[opt-in: gated-redirect only] BLOCKING: Resend audience is being written to. After launch, audience contact count must be > 0 within 24 hours of the first non-zero pageview, OR within 7 days of go-live, whichever comes first. Closes the studyly 2026-04-28 bug where the audience existed and the env var was set in .env.local but RESEND_AUDIENCE_ID was missing entirely on the deploy target's runtime, the route's audience-upsert step silently no-op'd, and the audience stayed at 0 contacts despite real signups landing in Neon. Verify with the master Resend key from keychain (defaults.resend_master_key_keychain in ~/social-autoposter/config.json):
Must print a positive integer once the site has had any real signups. Cross-check against signups table row count in Neon — they should match within ±1 (audience may dedupe a re-signup). A mismatch where Neon has rows and audience is empty means the runtime is missing RESEND_AUDIENCE_ID (re-run gcloud run services describe <svc> --format="value(spec.template.spec.containers[0].env)" or vercel env pull).
[opt-in: gated-redirect only] BLOCKING: dashboard Email Signups column populates. A successful submit must fire two PostHog events: get_started_click (from GetStartedButton.onClick, captures gate-open intent) and newsletter_subscribed (from EmailGateModal post-submit, populates the dashboard's Email Signups column). After the first real signup, run from ~/social-autoposter:
Both must be ≥ 1. If email_signups=0 while real signups exist in the Resend audience, EmailGateModal is missing the newsletter_subscribed capture.
BLOCKING: inbound email roundtrip works (Phase 3.5k exit criterion). From i@m13v.com, send a real email to matt@<DOMAIN>. Within 60s verify (a) Cloud Run logs show [<Slug> Webhook] email.received <id>, (b) <slug>_emails has a fresh direction='inbound' row with from_email='i@m13v.com', status='received', and (c) i@m13v.com inbox has a forwarded message with subject prefix [<Slug> Inbound]. If any step fails, the receiving DNS/webhook/handler is broken and replies will silently bounce. Studyly 2026-05-05: 6 captured signups never made it to Jungle, but their replies to "did the redirect break?" had no inbox to land in. Do not ship without this verification.
BLOCKING — analytics wiring audit passes. Run cd ~/social-autoposter && python3 scripts/check_analytics_wiring.py and verify the new site shows OK (exit code 0 when every site passes). The audit catches two classes of silent failure: (a) hand-rolled provider that fails to assign window.posthog so library helpers no-op, and (b) CTA-shaped event names outside the canonical set (cta_clicked with a trailing d, download_email_sent, waitlist_signup, contact_submitted, get_leads_signup, landing_cta_clicked, etc.) that the cross-project dashboard will silently ignore. Do not ship the site if this audit reports violations for it. The historical failure mode is: the manual "click one CTA and see it in PostHog" check succeeds because the event name looks plausible, but the dashboard queries cta_click / get_started_click / schedule_click / newsletter_subscribed exclusively (see ~/social-autoposter/scripts/project_stats_json.py:238, 302-306), so invented names are invisible forever.
BLOCKING — deploy wiring audit passes. Run cd ~/social-autoposter && python3 scripts/check_deploy_wiring.py and verify the new site shows OK. Closes the gap that left studyly without auto-deploy on 2026-04-28: a Cloud Run client site got registered in config.json with production_trigger: "manual" because Phase 6f (workflow + WIF) was skipped, and nothing in the pipeline noticed. The audit fails when (a) target=cloudrun and production_trigger=push:main but .github/workflows/deploy-cloudrun.yml is missing (push-to-main is a no-op), (b) the same target/trigger pair where the workflow exists but does not actually trigger on push to main, or (c) production_trigger=manual with no workflow at all (the studyly state). Do not register a cloudrun site in config.json with production_trigger: "push:main" until 6f is complete and this audit returns 0.
[opt-in: gated-redirect only] BLOCKING: destination-leak audit passes. With gated-redirect on, the brand and app domain must NOT appear in any user-visible source under src/ outside the /api/signup welcome-email payload. Closes the studyly leak found 2026-04-28 where the destination app.jungleai.com was printed in 11+ places (homepage final CTA, /faq Q&A, /about, footer, metadata.description, OpenGraph, JSON-LD sameAs, three /t/ topic pages, an /alternative/ page, and /privacy//terms) despite the anchor-level rule already being in place. Run from the website repo root, with BRAND set to the brand domain stem (e.g. jungleai.com) and APP set to the app subdomain (e.g. app.jungleai.com):
BRAND="jungleai.com"# set per client
APP="app.${BRAND}"# 1. Source code: every .tsx, .ts, .css, .md under src/ must be clean of $APP and $BRAND, except the welcome-email block in src/app/api/signup/route.ts.
leaks=$(grep -rn -E "${APP}|${BRAND}" src/ \
--include='*.tsx' --include='*.ts' --include='*.css' --include='*.md' \
| grep -v "src/app/api/signup/route.ts" \
| grep -v "src/lib/redirect.ts" \
| grep -vE "// |/\* |\* @" )
if [ -n "$leaks" ]; thenecho"FAIL leaks in source:"; echo"$leaks"; exit 1; fi# 2. Rendered HTML for every static route: pull the page and grep the body.for path in / /about /faq /privacy /terms $(ls src/app/\(main\)/t 2>/dev/null | xargs -I{} echo /t/{}); do
html=$(curl -sS "http://localhost:3000$path") || continue
count=$(printf"%s""$html" | grep -ic "${APP}\|${BRAND}" || true)
if [ "$count" -gt 0 ]; thenecho"FAIL leak on $path: $count matches"; exit 1; fidoneecho OK
Failure mode this catches:metadata.description mentions app.<brand>.com (renders into <head> and search snippets), Organization JSON-LD has sameAs: ["https://<brand>.com"] (visible in HTML source and indexed), a topic-page final CTA paragraph names the destination, the welcome-email footer template inside route.ts mentions the URL outside the actual access link. Each one independently defeats the gate.
window.posthog defined on every public page (DevTools console: window.posthog returns an object, not undefined)
[opt-in: book-a-call only] Cal.com team webhook registered → verified with one real booking landing in Neon cal_bookings with the client's client_slug, then cancelled (see Phase 6g)
[opt-in: book-a-call only]scripts/project_stats.pyget_client_slug() maps the project name to the client's slug
[opt-in: get-started only] one real click on the primary Get-Started CTA (download, install, or signup button) fires a get_started_click event visible in PostHog Activity for the site's $host within ~30 seconds (verifies trackGetStartedClick is reaching window.posthog, not silently no-op'ing). Use the HogQL API as a machine check, not just the UI: curl -s -H "Authorization: Bearer $POSTHOG_PERSONAL_API_KEY" "$POSTHOG_HOST/api/projects/$POSTHOG_PROJECT_ID/query/" -H 'content-type: application/json' -d '{"query":{"kind":"HogQLQuery","query":"SELECT count() FROM events WHERE event = '"'"'get_started_click'"'"' AND properties.$host = '"'"'DOMAIN'"'"' AND timestamp > now() - interval 5 minute"}}' must return >= 1. Same pattern for newsletter_subscribed on a test email submit and schedule_click on a book-a-call click. Do not ship if any in-scope event returns 0.
config.json entry has posthog.project_id (real numeric id, never REPLACE_WITH_..._ID), posthog.api_key_env; if book-a-call in scope, also has top-level booking_link (the real https://cal.com/team/<TEAM>/<SLUG> URL, never /contact//install); if get-started in scope, also has top-level get_started_link (the real self-serve destination URL, not a placeholder). Stats pipeline dry-run shows non-zero pageviews / cta_click counts (and non-zero bookings if book-a-call in scope, and non-zero get_started_clicks after at least one real click if get-started in scope)
Phase 8 verifies the site itself is healthy. Phase 9 is the hand-off: the pipeline that writes /t/{slug}/ guide pages on a schedule (GSC queries + DataForSEO SERP) lives in ~/social-autoposter/ and is owned by the gsc-seo-page skill, not this one.
Invoke gsc-seo-page > "Onboarding a New Product" and run its five steps, then run step 6 below from this skill.
(already done in Phase 7c — verify via OK_GSC self-check, do not re-register).
Add the product to ~/social-autoposter/config.json with weight (default 10, matching top-tier projects; lower only if explicitly requested) and the five landing_pages fields (repo, github_repo, base_url, gsc_property, product_source).
Activate the launchd jobs via launchctl load (labels are <defaults.launchd_label_prefix>.social-gsc-seo and <defaults.launchd_label_prefix>.social-serp-seo; the prefix is read from ~/social-autoposter/config.json).
Backfill GSC queries with seo/fetch_gsc_queries.py --product ClientName.
Verify with select_product.py --require-gsc, inspect gsc_queries in Postgres, and force one run_gsc_pipeline.sh run.
Seed seo_keywords from the client blog inventory (skip if research/client-blog-inventory.json does not exist). Owned by this skill, runs after step 2 (config.json) and before step 5 (verify), so the first forced run_gsc_pipeline.sh run picks up a warm-start row. Run from the website repo root:
PRODUCT="ClientName"# must match config.json projects[].name exactly
INVENTORY="$(pwd)/research/client-blog-inventory.json"set -a && source ~/social-autoposter/.env && set +a
python3 - <<PY
import json, os, re, psycopg
from pathlib import Path
product = os.environ.get("PRODUCT") or "$PRODUCT"
inv = json.loads(Path(os.environ["INVENTORY"]).read_text())
def slugify(s):
return re.sub(r"[^a-z0-9-]+", "-", s.lower()).strip("-")
added = 0
with psycopg.connect(os.environ["DATABASE_URL"]) as conn, conn.cursor() as cur:
for row in inv:
if row.get("skip"):
continue
pq = row.get("primary_query")
if not pq:
continue
slug = row.get("suggested_slug") or slugify(pq)
cur.execute(
"""
INSERT INTO seo_keywords (product, keyword, slug, source, status)
VALUES (%s, %s, %s, 'client_blog', 'unscored')
ON CONFLICT (product, keyword) DO NOTHING
""",
(product, pq, slug),
)
added += cur.rowcount
for q in (row.get("secondary_queries") or [])[:3]:
cur.execute(
"""
INSERT INTO seo_keywords (product, keyword, slug, source, status)
VALUES (%s, %s, %s, 'client_blog', 'unscored')
ON CONFLICT (product, keyword) DO NOTHING
""",
(product, q, slugify(q)),
)
added += cur.rowcount
conn.commit()
print(f"Seeded {added} client_blog rows into seo_keywords for {product}")
PY
Verify the seed landed:
psql "$DATABASE_URL" -c "SELECT count(*) FROM seo_keywords WHERE product='ClientName' AND source='client_blog';"
The row count should match the non-skipped rows in the inventory plus secondary queries. If 0, the inventory file probably is empty or every row had skip: true; do not retry blindly, re-read 1c.1.c step 4 and the user's vetoes in client-blog-inventory.md.
Prerequisites that this skill is responsible for (check before handing off):
Site deployed at https://DOMAIN, /t/ scaffolding built, sitemap at https://DOMAIN/sitemap.xml
The client repo is cloned at the path you will write into landing_pages.repo
Everything else, including the exact commands, field semantics, and checklist, lives in gsc-seo-page. Do not duplicate that content here.
Phase 10: Register in the Social Autoposter Pipeline
Phase 9 wired the SEO pipeline (/t/{slug} page generation from GSC + SERP). Phase 10 wires the posting / engagement / DM pipeline so the Reddit, Twitter, LinkedIn, GitHub, Moltbook, and Octolens jobs in ~/social-autoposter/ can mention this product, match against its topics, and respect its voice.
The canonical source is ~/social-autoposter/config.json → projects[]. Each entry drives:
Which threads/posts across all platforms are scored as relevant (matched against topics, twitter_topics, linkedin_topics, github_search_topics)
What the engagement scripts can honestly say about the product (description, features, differentiator, icp)
What voice and banned phrases apply (voice.tone, voice.never)
Links inserted into replies (website, github, and the top-level booking_link / get_started_link, which are the fields the DM bot and dashboard actually read). The legacy links.* map is documentation-only: no current code path under ~/social-autoposter/ reads project["links"] at runtime. Skip it for new client sites unless an existing entry shows a strong precedent; the real CTA URLs belong in top-level booking_link / get_started_link.
10a. Build the project entry from the research brief
The research-brief.md produced in Phase 1f already has every field Phase 10 needs. Map it like this:
Mandatory fields for every new client site (do not skip):name, description, platform, weight, website, landing_pages.*, posthog.*, plus at least one of booking_link / get_started_link. The dashboard, DM bot, and stats pipeline assume these are present; omitting them silently breaks the project's row.
projects[] field
Source in research-brief.md
name
client name (lowercase slug, matches landing_pages.repo basename)
description
Mandatory. "Positioning angle" (one sentence). Read by seo/generate_page.py and scripts/post_reddit.py as the canonical one-line product pitch. Always include.
platform
Mandatory. One short sentence describing the product surface and primary CTA shape (e.g. "macOS desktop app (download .dmg)", "Web app at app. (signup)", "Web. Gates traffic to app..com (desktop, iOS, Google Play)."). Surfaced to operators on the dashboard and used by future filters. Always include.
differentiator
"3 differentiators" condensed into one sentence
icp
"ICP" section, primary persona description + JTBD
topics
"5 messaging pillars" rewritten as search-friendly keyword phrases. Mix broader category heads with niche long-tail. This field is dual-purpose: posting-eligibility matching AND DataForSEO keyword_suggestions seeds for SEO page discovery (seo/generate_keywords.py:216). Niche-only seed lists ("speak to write ghostwriting process") cause the SERP cron to return zero candidates because DataForSEO can only expand seeds with measurable volume; always include 3 to 5 broader heads ("ghostwriting services", "book ghostwriter") so the discovery funnel actually fills. See gsc-seo-page Step 2 for the full rationale
competitor_domains
extracted from research/raw/competitors.md (Phase 1a output): 5 to 7 root domains of direct category competitors. Drop marketplaces (reedsy, upwork) and personal-brand pages. Read by seo/generate_keywords.py to steal keywords via DataForSEO keywords_for_site. Single biggest lever for niche B2B funnels. See gsc-seo-page Step 2
min_keyword_volume
optional per-product override of the default 20/mo MIN_VOLUME floor. Set to 5 for niche B2B services where most queries fall below 20/mo. Omit for high-traffic categories (consumer SaaS, broad B2B horizontals). See gsc-seo-page Step 2
twitter_topics / linkedin_topics
same pillars re-tuned per platform (shorter for Twitter, more formal for LinkedIn)
features
"Proof points" list, converted to capability statements
voice.tone
derived from client intake (brand voice) + ICP language
voice.never
"Banned clichés" list, verbatim
booking_link (top-level, NOT links.booking)
[opt-in: book-a-call] — include ONLY if Book-a-Call is in scope. Then it's the actual cal.com URL the site's Book CTAs point at (https://cal.com/team/<TEAM>/<SLUG>) — not a placeholder like /contact or /install. If Book-a-Call is NOT in scope, omit the field entirely (do NOT set null, do NOT fake a URL); downstream consumers (engage-dm-replies.sh, bin/server.js, scripts/dm_conversation.py) must fall back to links.install or the primary CTA. links.booking is ignored at runtime.
get_started_link (top-level, NOT links.install)
[opt-in: get-started] — include ONLY if Get-Started is in scope. Then it's the actual primary self-serve destination the site's Get-Started CTAs point at: a download page (https://<DOMAIN>/download, App Store link, signed .dmg URL, Chrome Web Store listing), an install page, or a signup entry point (https://app.<DOMAIN>, /waitlist, /signup). Never a placeholder. If Get-Started is NOT in scope, omit the field entirely. get_started_link is what the dashboard reads for the "Get Started" column and what the DM bot shares when a site's primary CTA is self-serve rather than a sales call. A site may have both booking_link and get_started_link (Fazm: pilot call + Mac download; Assrt: pilot call + app.assrt.ai signup).
posthog.project_id
numeric PostHog project id this site writes to (usually the shared project from config.json > defaults.posthog_project_id unless the site has a dedicated project)
llms_txt (top-level)
path to the static brief shipped in Phase 3p, written as ~/<repo>/public/llms.txt. Every project in config.json has this; omitting it breaks AI-crawler discovery audits and the seo-geo check. Verify the file actually exists on disk and returns 200 at https://DOMAIN/llms.txt before writing the entry.
posthog.api_key_env
name of the env var holding the PostHog personal API key used to read stats (e.g. POSTHOG_PERSONAL_API_KEY). Stores the variable NAME, never the value — config.json is not for secrets.
booking_link and get_started_link are what skill/engage-dm-replies.sh, bin/server.js, and scripts/dm_conversation.py read when deciding which CTA URL to share in DMs or render in the dashboard. posthog.{project_id,api_key_env} are what scripts/project_stats_json.py reads to query pageviews + cta_click + schedule_click + get_started_click events filtered by properties.$host (booking counts come from the cal_bookings table keyed on client_slug, not from booking_link). Missing any of these means the client is dark on the Social Autoposter stats dashboard or the DM bot has no link to share, even if the site itself is firing events.
If a field has no good source in the brief, leave it out rather than invent. Never fabricate features, stats, or differentiators — every claim in projects[] is used verbatim in public replies across all platforms.
Secrets reminder:config.json is gitignored from the social-autoposter repo but is not a secrets store. Only put references to env vars and keychain entries there (api_key_env, *_keychain), never the key itself. Actual values live in ~/social-autoposter/.env, macOS Keychain, and Cloud Run runtime env.
10b. Inspect an existing entry as template
Before writing, read one existing entry to match the schema exactly:
Copy the shape. Do not invent fields the pipeline does not already consume.
10c. Idempotent append
Write the entry to a temp JSON file, then merge:
NEW="/tmp/new-project.json"# full entry for the client
CFG="$HOME/social-autoposter/config.json"
NAME=$(jq -r '.name'"$NEW")
# 0. Validate new entry parses
jq empty "$NEW" || { echo"invalid JSON in $NEW"; exit 1; }
# 1. Backupcp"$CFG""$CFG.bak.$(date +%Y%m%d-%H%M%S)"# 2. Abort if name already present (don't silently overwrite)if jq -e --arg n "$NAME"'.projects[] | select(.name == $n)'"$CFG" >/dev/null; thenecho"Project '$NAME' already in config.json — edit by hand or remove the existing entry first."exit 1
fi# 3. Append, validate, show diff, then write
jq --argjson new "$(cat "$NEW")"'.projects += [$new]'"$CFG" > "$CFG.tmp"
jq empty "$CFG.tmp" || { echo"merge produced invalid JSON"; rm"$CFG.tmp"; exit 1; }
diff <(jq '.projects | map(.name)'"$CFG") <(jq '.projects | map(.name)'"$CFG.tmp")
mv"$CFG.tmp""$CFG"
Review the diff before mv. If it shows anything other than a single project-name addition, abort.
10d. Merge with Phase 9's landing_pages block
If Phase 9 already ran and added a partial entry with only landing_pages + weight, do not duplicate. Instead, detect the existing entry and merge Phase 10's fields into it:
jq --arg n "$NAME" --argjson new "$(cat "$NEW")"'
.projects |= map(if .name == $n then . + $new else . end)
'"$CFG" > "$CFG.tmp" && mv"$CFG.tmp""$CFG"
Phase 9 fields (weight, landing_pages) are never overwritten by Phase 10. Phase 10 fields (description, topics, features, voice, etc.) are never touched by Phase 9.
10e. Smoke test
After writing, confirm the posting pipeline can see the new project:
cd ~/social-autoposter && python3 -c "
import json
d = json.load(open('config.json'))
p = next((x for x in d['projects'] if x['name'] == '$NAME'), None)
assert p, 'not found'
for k in ['description','topics','features','voice','website']:
assert k in p, f'missing {k}'
print('ok:', p['name'], '-', p['description'][:80])
"
Then do a single dry-run of one engagement script to make sure the new entry does not crash the matcher (replace engage-reddit.sh with whichever script is least expensive to dry-run):
If the dry-run surfaces a schema mismatch, fix the new entry before letting real runs fire.
10f. Exit criteria
Phase 10 is done when all of these are true:
jq '.projects[] | select(.name=="<client>")' config.json returns a full entry (not just landing_pages)
topics, twitter_topics, linkedin_topics, and features all trace to the research brief
topics includes at least 3 broader category heads in addition to niche long-tail phrases (so DataForSEO discovery has high-volume seeds to expand from)
competitor_domains has 5 to 7 root domains pulled from research/raw/competitors.md (or the field is intentionally omitted because the product has no direct competitors, which is rare and worth flagging)
min_keyword_volume: 5 is set if the product is a niche B2B service (otherwise omit and rely on the default)
voice.never matches the brief's "Banned clichés" list verbatim
At least one engagement script dry-run completes without a schema error
config.json.bak.* backup exists in case of rollback
Only after Phase 10 passes is the client actually "in the pipeline." Until then, they have a website but no posting, no engagement, no DM outreach, and no Octolens mention tracking.
Quick Reference: File Structure
~/CLIENT-website/
public/
images/
logo.png # Client logo
founder.png # Founder headshot
team-photo.png # Team photo
client-1.png # Client headshots for testimonials
client-2.png
product-1.png # Product images
book-covers-strip.png # Product gallery (if applicable)
src/
app/
globals.css # Tailwind 4 theme with brand colors
layout.tsx # Root layout: fonts, metadata, JSON-LD, plus the three-column flex row that mounts SitemapSidebar + {children} + GuideChatPanel (see Phase 2d). No Header/Footer here.
sitemap.ts # One-liner: generateSitemap({ baseUrl })
sitemap/page.tsx # Human-readable HTML sitemap via HtmlSitemap
robots.ts # Crawl directives
(main)/ # Route group: all pages with site Header/Footer
layout.tsx # Adds Header + Footer around children
page.tsx # Homepage
about/page.tsx
wins/page.tsx
how-it-works/page.tsx
precall/page.tsx
faq/page.tsx
blog/page.tsx
testimonials/page.tsx
privacy-policy/page.tsx
t/ # SEO guide pages (inside (main), so they get Header/Footer)
guide-topic-1/page.tsx
guide-topic-2/page.tsx
components/
Header.tsx
Footer.tsx
FAQItem.tsx # Accordion client component
No site-sidebar.tsx / site-sidebar-client.tsx wrappers.SitemapSidebar and GuideChatPanel are imported straight from @seo/components into the root layout (Phase 2d). The library handles its own "use client" boundary — wrapping it in a custom component is an older pattern that should not be reintroduced.