packages/skills/skills/setup-client-website/SKILL.md
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.
npx skillsauth add mediar-ai/skillhubz setup-client-websiteInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
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.
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.
Provide the client name, domain (if any), and existing site URL (if any). Example: "Paperback Expert at paperbackexpert.com"
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.
| Flag | Default | Triggers phases |
|------|---------|-----------------|
| book-a-call | off | 3.5d Book-a-Call helpers, 3.5k.1 /r/[code] short-link redirect, 3.5l Cal.com event type creation, 6g Cal.com webhook wiring, Phase 8 booking-verification row, Phase 10a booking_link field |
| get-started | off | 3.5d′ Get-Started helpers, Phase 8 get-started-verification row, Phase 10a get_started_link field |
| gated-redirect | off | 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 ([email protected] 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.
<BookCallLink>/<BookCallTracker>, no Cal.com event type, no booking_link in config.json.<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.
~/social-autoposter/config.json > defaults.gcp_organization_name (see 1.5e — per-client projects are created as <slug>-prod)config.json > defaults.posthog_org) for analytics[email protected]) for transactional email@theme)@neondatabase/serverless) for email + contact logsPhase 1 runs two tracks concurrently:
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.
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.
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.
Use an agent with WebFetch to discover and extract content from every page on the site:
Output: Complete content inventory organized by page.
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).
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.
| 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) |
primary_queryThe 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:
skip: brand-only and exclude it from the seed list. Brand queries belong on the brand site, not on our SEO domain.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):
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.
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.
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.
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:
π(a|s))/favicon.ico, /favicon.png, or any <link rel="icon"> href)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:
curl -sL <brand-url>/assets/index.css | grep -oE '\-\-[a-z-]+:[^;]+;' | sort -u
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:
magick <favicon>.png -define icon:auto-resize=16,32,48 src/app/favicon.ico
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
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)
<single crisp sentence — what the client uniquely is, for whom, against whom>
## 3 differentiators
1. <differentiator> — proof: <citation / source / datapoint>
2. ...
3. ...
## 5 messaging pillars
1. <pillar headline> — 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:
research/raw/ or the crawl inventory. No invented stats.research/raw/ are scratch and can be deleted after 1f lands.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:
For each reference, capture in research/visual-references.md:
research/visual-references/<slug>.png (full page via isolated browser).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.
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.
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.
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.camera → cam, cmra, kmr; security → sec, scrty).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..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.Track B: Keyword-rich descriptive names (fallback). Use when Track A returns nothing available under the price cap.
apartment-security-cameras.com, not best.cameras.for.apartments.com)..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.
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:
gcloud domains registrations search-domains "apartment security camera" --format=json
The search-domains response is cached and fast; use it for breadth. Append any attractive suggestions to research/domain-candidates.md.
For every candidate, run:
gcloud domains registrations get-register-parameters <candidate> --format=json 2>&1
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:
availability != AVAILABLE.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.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:
.com > .io > .co > others. .ai is excluded..com beats everything else; ties broken by shorter length..com if any .com is available across either track.Wait for the user to pick one. Never pre-select. Never auto-buy.
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: [email protected]
phoneNumber: '+1.XXXXXXXXXX'
postalAddress:
regionCode: US
postalCode: 'XXXXX'
administrativeArea: CA
locality: San Francisco
addressLines: ['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
(Example: fde10x.com → fde10x-zone inside project fde10x-prod.)
Purchase flow:
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.--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.
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).
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.
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.
Example: for Pias Tech on fde10x.com:
π(a|s) PIAS AI (brand)© 2026 PIAS AI. All rights reserved. (brand){ "name": "PIAS AI", "legalName": "PIAS - Policy-Informed Agentic Systems" } (brand)[email protected] (domain)https://fde10x.com (domain)"PIAS AI <[email protected]>" (both: brand label + domain address)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.
cd ~
npx create-next-app@latest CLIENT-website --typescript --tailwind --eslint --app --src-dir --no-turbopack --import-alias "@/*"
cd ~/CLIENT-website
npm install @m13v/seo-components@latest
npm install @seo/components@npm:@m13v/seo-components@latest
Both entries will appear in package.json pinned to whatever npm @latest resolves to (≥0.22.1 at the time of writing). Example:
"@m13v/seo-components": "^0.22.1",
"@seo/components": "npm:@m13v/seo-components@^0.22.1"
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.
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@layerblock. 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).@keyframesat 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.
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"] });
export default function RootLayout({ children }: { children: React.ReactNode }) {
const pages = walkPages();
return (
<html lang="en" className={`${inter.variable} ${oswald.variable} h-full antialiased`}>
{/* Body is min-h-full only. DO NOT add `flex flex-col` here — the flex row below is the layout. */}
<body className="min-h-full font-sans">
<HeadingAnchors />
<div className="flex min-h-screen">
<SitemapSidebar pages={pages} brandName="CLIENT_NAME" hideOnPaths={["/"]} />
<div className="flex-1 min-w-0 flex flex-col">
{children}
</div>
<GuideChatPanel app="CLIENT_SLUG" hideOnPaths={["/"]} />
</div>
{/* Organization JSON-LD here */}
</body>
</html>
);
}
HeadingAnchors auto-injects id attributes on H2 elements for sidebar linking and anchor navigation.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.<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.
import { Header } from "@/components/Header";
import { Footer } from "@/components/Footer";
export default function MainLayout({ children }: { children: React.ReactNode }) {
return (
<>
<Header />
<main className="flex-1">{children}</main>
<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:
/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.Array.from(document.querySelectorAll('aside')).map(a => ({
top: Math.round(a.getBoundingClientRect().top),
left: Math.round(a.getBoundingClientRect().left),
w: Math.round(a.getBoundingClientRect().width),
}))
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.Font pairing guide:
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.
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.
background, ink, accent, signal, optional muted and rule. Grounded in research/brand-identity.md. No arbitrary picks.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).
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.
Sticky nav with logo, dropdown menus, mobile hamburger, and CTA button.
"use client";
import Image from "next/image";
import Link from "next/link";
import { useState } from "react";
// PATTERN: Separate simple links from dropdown groups
const navLinks = [
{ href: "/", label: "Home" },
{ href: "/how-it-works", label: "How It Works" },
{ href: "/wins", label: "Client Results" },
];
const dropdowns = [
{
label: "About",
items: [
{ href: "/about", label: "Our Story" },
{ href: "/faq", label: "FAQ" },
{ href: "/blog", label: "Blog" },
{ href: "/contact", label: "Contact" },
],
},
// Add more dropdown groups as needed
];
export function Header() {
const [mobileOpen, setMobileOpen] = useState(false);
return (
<header className="sticky top-0 z-50 bg-white border-b border-gray-100 shadow-sm">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div className="flex h-16 items-center justify-between">
{/* Logo: always use next/image with priority */}
<Link href="/" className="flex items-center">
<Image src="/images/logo.png" alt="CLIENT_NAME" width={200} height={66} priority />
</Link>
{/* Desktop Nav */}
<nav className="hidden lg:flex items-center gap-6">
{navLinks.map((link) => (
<Link
key={link.href}
href={link.href}
className="font-heading text-sm font-semibold uppercase tracking-wider text-gray-700 hover:text-primary transition-colors"
/>
))}
{/* DROPDOWN PATTERN: group + group-hover for CSS-only dropdowns */}
{dropdowns.map((dropdown) => (
<div key={dropdown.label} className="relative group">
<button className="font-heading text-sm font-semibold uppercase tracking-wider text-gray-700 hover:text-primary transition-colors flex items-center gap-1">
{dropdown.label}
<svg className="h-4 w-4 transition-transform group-hover:rotate-180" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
<div className="invisible opacity-0 group-hover:visible group-hover:opacity-100 transition-all duration-200 absolute top-full left-0 mt-1 w-56 rounded-md bg-white shadow-lg ring-1 ring-black/5 py-2 z-50">
{dropdown.items.map((item) => (
<Link key={item.href} href={item.href} className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 hover:text-primary transition-colors" />
))}
</div>
</div>
))}
{/* CTA BUTTON in nav */}
<Link
href="/precall"
className="rounded-md bg-cta px-5 py-2.5 font-heading text-sm font-semibold uppercase tracking-wider text-white hover:bg-cta-dark transition-colors"
/>
</nav>
{/* MOBILE HAMBURGER */}
<button className="lg:hidden p-2 text-gray-700" onClick={() => setMobileOpen(!mobileOpen)}>
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
{mobileOpen ? (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
) : (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
)}
</svg>
</button>
</div>
{/* MOBILE NAV: flat list with section headers */}
{mobileOpen && (
<nav className="lg:hidden pb-4 space-y-1">
{navLinks.map((link) => (
<Link key={link.href} href={link.href} className="block py-2 font-heading text-sm font-semibold uppercase tracking-wider text-gray-700 hover:text-primary" onClick={() => setMobileOpen(false)} />
))}
{dropdowns.map((dropdown) => (
<div key={dropdown.label}>
<span className="block py-2 font-heading text-xs font-bold uppercase tracking-wider text-gray-400 mt-2">{dropdown.label}</span>
{dropdown.items.map((item) => (
<Link key={item.href} href={item.href} className="block py-2 pl-4 font-heading text-sm font-semibold uppercase tracking-wider text-gray-700 hover:text-primary" onClick={() => setMobileOpen(false)} />
))}
</div>
))}
<Link href="/precall" className="block rounded-md bg-cta px-5 py-2.5 text-center font-heading text-sm font-semibold uppercase tracking-wider text-white hover:bg-cta-dark mt-3" onClick={() => setMobileOpen(false)} />
</nav>
)}
</div>
</header>
);
}
Key classes: sticky top-0 z-50, font-heading text-sm font-semibold uppercase tracking-wider, invisible opacity-0 group-hover:visible group-hover:opacity-100
4-column layout: Brand + description, Company links, Resource links, CTA + contact info. Social icons row at bottom.
import Image from "next/image";
import Link from "next/link";
const companyLinks = [
{ href: "/about", label: "About" },
{ href: "/how-it-works", label: "How It Works" },
{ href: "/faq", label: "FAQ" },
{ href: "/contact", label: "Contact" },
{ href: "/blog", label: "Blog" },
{ href: "/privacy-policy", label: "Privacy Policy" },
{ href: "/sitemap", label: "Sitemap" }, /* REQUIRED: keeps the HTML sitemap discoverable */
];
const resourceLinks = [
{ href: "/wins", label: "Client Results" },
{ href: "/testimonials", label: "Testimonials" },
// ...more resource links
];
export function Footer() {
return (
<footer className="bg-primary-dark text-white">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-12">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
{/* Column 1: Brand */}
<div>
<div className="mb-4">
<Image src="/images/logo.png" alt="CLIENT_NAME" width={200} height={66} />
</div>
<p className="text-gray-300 text-sm leading-relaxed">
Short brand description here.
</p>
</div>
{/* Column 2: Company links */}
<div>
<h3 className="font-heading text-sm font-semibold uppercase tracking-wider text-accent mb-4">Company</h3>
<ul className="space-y-2">
{companyLinks.map((link) => (
<li key={link.href}>
<Link href={link.href} className="text-gray-300 hover:text-white text-sm transition-colors">{link.label}</Link>
</li>
))}
</ul>
</div>
{/* Column 3: Resource links */}
<div>
<h3 className="font-heading text-sm font-semibold uppercase tracking-wider text-accent mb-4">Resources</h3>
<ul className="space-y-2">
{resourceLinks.map((link) => (
<li key={link.href}>
<Link href={link.href} className="text-gray-300 hover:text-white text-sm transition-colors">{link.label}</Link>
</li>
))}
</ul>
</div>
{/* Column 4: CTA + Contact */}
<div>
<h3 className="font-heading text-sm font-semibold uppercase tracking-wider text-accent mb-4">Ready to Get Started?</h3>
<p className="text-gray-300 text-sm mb-4">Short CTA teaser text.</p>
<Link href="/precall" className="inline-block rounded-md bg-cta px-6 py-3 font-heading text-sm font-semibold uppercase tracking-wider text-white hover:bg-cta-dark transition-colors mb-6" />
<div className="space-y-1 text-sm text-gray-300">
<p><a href="mailto:EMAIL" className="hover:text-white transition-colors">EMAIL</a></p>
<p><a href="tel:+1PHONE" className="hover:text-white transition-colors">PHONE</a></p>
<p>CITY, STATE</p>
</div>
</div>
</div>
{/* SOCIAL ICONS ROW */}
<div className="mt-8 flex justify-center gap-4">
{/* Each social: circular button with SVG icon */}
{socialLinks.map((social) => (
<a key={social.label} href={social.href} aria-label={social.label}
className="flex h-10 w-10 items-center justify-center rounded-full bg-gray-700 text-white transition-colors hover:bg-accent hover:text-primary">
{social.icon}
</a>
))}
</div>
{/* COPYRIGHT */}
<div className="mt-8 border-t border-gray-600 pt-8 text-center">
<p className="text-gray-400 text-sm">© {new Date().getFullYear()} CLIENT_NAME. All rights reserved.</p>
</div>
</div>
</footer>
);
}
Key classes: bg-primary-dark text-white, text-accent for section headers, h-10 w-10 rounded-full bg-gray-700 hover:bg-accent hover:text-primary for social icons
These are the exact section patterns used throughout the site. Each section follows a consistent structure.
<section className="bg-{COLOR} py-20 sm:py-28">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
{/* content */}
</div>
</section>
bg-white or bg-gray-50bg-primary with white textpy-20 sm:py-28 (standard), py-16 sm:py-24 (compact), py-12 (stats bar)max-w-7xl for grids, max-w-3xl for prose, max-w-4xl for CTAs<div className="text-center max-w-2xl mx-auto mb-16">
<h2 className="font-heading text-3xl sm:text-4xl font-bold text-primary mb-4">
Section Title
</h2>
<p className="text-gray-600 text-lg">
Section subtitle/description text.
</p>
</div>
On dark backgrounds, use text-white for h2 and text-gray-300 for description.
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.
The homepage follows this exact section order:
<section className="relative bg-white overflow-hidden">
<div className="relative mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-24 sm:py-32 lg:py-40">
<div className="max-w-3xl">
<p className="font-heading text-primary text-sm sm:text-base font-semibold uppercase tracking-[0.2em] mb-4">
TAGLINE / CATEGORY
</p>
<h1 className="font-heading text-4xl sm:text-5xl lg:text-6xl font-bold text-primary leading-tight mb-6">
Main Headline <span className="text-accent">(Accent Phrase)</span>
</h1>
<p className="text-lg sm:text-xl text-gray-700 leading-relaxed mb-4">
Primary subheadline.
</p>
<p className="text-base sm:text-lg text-gray-500 leading-relaxed mb-10">
Secondary description with more detail.
</p>
<div className="flex flex-col sm:flex-row gap-4">
{/* PRIMARY CTA */}
<Link href="/wins" className="inline-flex items-center justify-center rounded-md bg-cta px-8 py-4 font-heading text-base font-semibold uppercase tracking-wider text-white hover:bg-cta-dark transition-colors">
Primary Action
</Link>
{/* SECONDARY CTA (outline) */}
<Link href="/about#contact" className="inline-flex items-center justify-center rounded-md border-2 border-primary px-8 py-4 font-heading text-base font-semibold uppercase tracking-wider text-primary hover:bg-primary hover:text-white transition-colors">
Secondary Action
</Link>
</div>
</div>
</div>
</section>
Key details:
font-heading text-base font-semibold uppercase tracking-widerpx-8 py-4py-24 sm:py-32 lg:py-40<span className="text-accent"><section className="bg-white py-8 overflow-hidden">
<div className="mx-auto max-w-7xl px-4">
<Image src="/images/product-strip.png" alt="Description" width={1116} height={134} className="w-full h-auto" />
</div>
</section>
<section className="bg-white border-b border-gray-100">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-12">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-8">
{stats.map((stat) => (
<div key={stat.label} className="text-center">
<p className="font-heading text-4xl sm:text-5xl font-bold text-primary">{stat.value}</p>
<p className="mt-2 text-sm font-semibold uppercase tracking-wider text-gray-500">{stat.label}</p>
</div>
))}
</div>
</div>
</section>
Data shape: { value: "275+", label: "Books Published" }
<section className="bg-gray-50 py-20 sm:py-28">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
{/* Section header (centered) */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{benefits.map((benefit) => (
<div key={benefit.title} className="bg-white rounded-xl p-8 shadow-sm border border-gray-100 hover:shadow-md transition-shadow">
{/* Icon in colored circle */}
<div className="inline-flex items-center justify-center w-14 h-14 rounded-lg bg-primary/10 text-primary mb-6">
{benefit.icon}
</div>
<h3 className="font-heading text-xl font-bold text-primary mb-3">{benefit.title}</h3>
<p className="text-gray-600 leading-relaxed">{benefit.description}</p>
</div>
))}
</div>
</div>
</section>
Card pattern: bg-white rounded-xl p-8 shadow-sm border border-gray-100 hover:shadow-md transition-shadow
Icon container: w-14 h-14 rounded-lg bg-primary/10 text-primary
<section className="bg-white py-20 sm:py-28">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
{/* Section header (centered) */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
{steps.map((step) => (
<div key={step.step} className="relative">
<div className="text-6xl font-heading font-bold text-primary/10 mb-2">{step.step}</div>
<h3 className="font-heading text-xl font-bold text-primary mb-3">{step.title}</h3>
<p className="text-gray-600 leading-relaxed">{step.description}</p>
</div>
))}
</div>
{/* Optional team/product photo below */}
<div className="mt-12">
<Image src="/images/team-photo.png" alt="Team" width={1024} height={768} className="w-full h-auto rounded-xl shadow-lg" />
</div>
</div>
</section>
Step number: text-6xl font-heading font-bold text-primary/10 (large, faded background number)
<section className="bg-primary py-20 sm:py-28">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div className="text-center max-w-2xl mx-auto mb-16">
<h2 className="font-heading text-3xl sm:text-4xl font-bold text-white mb-4">Real Results from Real Clients</h2>
<p className="text-gray-300 text-lg">Description text.</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{testimonials.map((t) => (
<div key={t.name} className="bg-white/5 border border-white/10 rounded-xl p-8 backdrop-blur-sm">
{/* Star rating */}
<div className="flex items-center gap-1 mb-4">
{[...Array(5)].map((_, i) => (
<svg key={i} className="h-5 w-5 text-accent" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
))}
</div>
{/* Quote */}
<blockquote className="text-white/90 leading-relaxed mb-6 italic">
“{t.quote}”
</blockquote>
{/* Author with headshot */}
<div className="border-t border-white/10 pt-4 flex items-start gap-4">
<Image src={t.image} alt={t.name} width={64} height={64} className="w-16 h-16 rounded-full object-cover flex-shrink-0" />
<div>
<p className="font-heading font-bold text-white">{t.name}</p>
<p className="text-gray-400 text-sm">{t.title}</p>
<p className="mt-2 text-accent font-heading font-semibold text-sm uppercase tracking-wider">{t.result}</p>
</div>
</div>
</div>
))}
</div>
{/* CTA button centered below */}
<div className="text-center mt-12">
<Link href="/wins" className="inline-flex items-center justify-center rounded-md bg-cta px-8 py-4 font-heading text-base font-semibold uppercase tracking-wider text-white hover:bg-cta-dark transition-colors">
See All Client Results
</Link>
</div>
</div>
</section>
Glass card: bg-white/5 border border-white/10 rounded-xl p-8 backdrop-blur-sm
Star SVG path: M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z
<section className="bg-white py-20 sm:py-28">
<div className="mx-auto max-w-4xl px-4 sm:px-6 lg:px-8 text-center">
<h2 className="font-heading text-3xl sm:text-4xl font-bold text-primary mb-6">
Ready to Get Started?
</h2>
<p className="text-gray-600 text-lg mb-10 max-w-2xl mx-auto">
Join our clients who have used [PRODUCT] to grow their business.
</p>
<div className="flex flex-col sm:flex-row justify-center gap-4">
<Link href="/precall" className="inline-flex items-center justify-center rounded-md bg-cta px-8 py-4 font-heading text-base font-semibold uppercase tracking-wider text-white hover:bg-cta-dark transition-colors">
Primary CTA
</Link>
<Link href="/wins" className="inline-flex items-center justify-center rounded-md border-2 border-primary px-8 py-4 font-heading text-base font-semibold uppercase tracking-wider text-primary hover:bg-primary hover:text-white transition-colors">
Secondary CTA
</Link>
</div>
</div>
</section>
Used on About, Wins, FAQ, and all non-homepage pages:
<section className="bg-primary py-16 sm:py-24">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 text-center">
<h1 className="font-heading text-4xl sm:text-5xl lg:text-6xl font-bold text-white mb-6">
Page Title
</h1>
<p className="text-gray-300 text-lg sm:text-xl max-w-2xl mx-auto">
Page subtitle.
</p>
</div>
</section>
Variant with gradient overlay (precall, FAQ):
<section className="relative bg-primary overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-br from-primary-dark to-primary opacity-90" />
<div className="relative mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-24 sm:py-32">
{/* content with z-index above gradient */}
</div>
</section>
Variant with accent highlight (FAQ):
<h1 className="font-heading text-4xl sm:text-5xl lg:text-6xl font-bold text-white leading-tight">
Frequently Asked <span className="text-accent">Questions</span>
</h1>
Client component with useState for expand/collapse:
"use client";
import { useState } from "react";
export function FAQItem({ question, answer }: { question: string; answer: string }) {
const [isOpen, setIsOpen] = useState(false);
return (
<div className="border border-gray-100 rounded-xl overflow-hidden">
<button
onClick={() => setIsOpen(!isOpen)}
className="w-full flex items-center justify-between p-6 text-left bg-white hover:bg-gray-50 transition-colors"
>
<span className="font-heading text-lg font-bold text-primary pr-4">{question}</span>
<svg
className={`h-6 w-6 text-primary shrink-0 transition-transform ${isOpen ? "rotate-180" : ""}`}
fill="none" viewBox="0 0 24 24" stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{isOpen && (
<div className="px-6 pb-6 bg-white">
<p className="text-gray-600 leading-relaxed">{answer}</p>
</div>
)}
</div>
);
}
Usage: Render in a <div className="space-y-4"> container within a bg-gray-50 section. Always include JSON-LD FAQPage schema alongside.
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).
interface CaseStudy {
name: string;
location: string;
company: string;
industry: string;
books: string[]; // or products, services
quote: string;
results: string[];
highlight: string; // e.g., "30 to 40x ROI"
headshot: string;
bookCover?: string; // optional product image
}
{/* Card with alternating bg */}
<article className={`rounded-2xl border border-gray-100 overflow-hidden shadow-sm ${idx % 2 === 0 ? "bg-gray-50" : "bg-white"}`}>
<div className="p-8 sm:p-12">
<div className="flex flex-col lg:flex-row lg:items-start gap-8">
{/* LEFT COLUMN (2/5): Info */}
<div className="lg:w-2/5">
{/* Industry badge */}
<span className="inline-block bg-primary text-white text-xs font-heading font-semibold uppercase tracking-wider px-3 py-1 rounded-full">
{cs.industry}
</span>
{/* Headshot + name */}
<div className="flex items-center gap-4 mb-4">
<Image src={cs.headshot} alt={cs.name} width={80} height={80} className="rounded-full object-cover w-20 h-20" />
<div>
<h3 className="font-heading text-2xl sm:text-3xl font-bold text-primary mb-1">{cs.name}</h3>
<p className="text-gray-500 mb-0.5">{cs.company}</p>
<p className="text-gray-400 text-sm">{cs.location}</p>
</div>
</div>
{/* Book/product tags */}
<div className="flex flex-wrap gap-2">
{cs.books.map((book) => (
<span key={book} className="inline-block bg-accent/20 text-primary text-sm font-medium px-3 py-1 rounded-md italic">{book}</span>
))}
</div>
{/* Highlight metric box */}
<div className="bg-primary rounded-lg p-4 text-center">
<p className="text-accent font-heading font-bold text-xl">{cs.highlight}</p>
</div>
</div>
{/* RIGHT COLUMN (3/5): Quote + Results */}
<div className="lg:w-3/5">
<blockquote className="text-lg sm:text-xl text-gray-700 italic leading-relaxed mb-8 border-l-4 border-accent pl-6">
“{cs.quote}”
</blockquote>
<p className="text-sm font-semibold uppercase tracking-wider text-gray-500 mb-3">Key Results</p>
<ul className="space-y-2">
{cs.results.map((result) => (
<li key={result} className="flex items-start gap-3">
<svg className="h-5 w-5 text-green-500 mt-0.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span className="text-gray-700">{result}</span>
</li>
))}
</ul>
</div>
</div>
</div>
</article>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{testimonials.map((t) => (
<div key={t.name} className="bg-white rounded-xl p-6 shadow-sm border border-gray-100 hover:shadow-md transition-shadow flex flex-col">
{/* Star rating */}
<div className="flex items-center gap-1 mb-3">
{[...Array(5)].map((_, i) => (
<svg key={i} className="h-4 w-4 text-accent" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l..." />
</svg>
))}
</div>
<blockquote className="text-gray-700 italic leading-relaxed mb-4 flex-1">
“{t.quote}”
</blockquote>
<div className="border-t border-gray-100 pt-3">
<p className="font-heading font-bold text-primary text-sm">{t.name}</p>
<p className="text-gray-500 text-xs">{t.company}</p>
{t.result && (
<p className="mt-1 text-accent font-heading font-semibold text-xs uppercase tracking-wider">{t.result}</p>
)}
</div>
</div>
))}
</div>
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
/installor/signupwith 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.
{/* Hero with founder headshot + gradient */}
<section className="relative bg-primary overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-br from-primary-dark to-primary opacity-90" />
<div className="relative mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-24 sm:py-32">
<div className="max-w-3xl mx-auto text-center">
<Image src="/images/founder.png" alt="Founder Name" width={200} height={200} className="rounded-full mx-auto mb-6" />
<h1 className="font-heading text-4xl sm:text-5xl lg:text-6xl font-bold text-white leading-tight mb-6">
Ready to Get Started? <span className="text-accent">Book a Call Today!</span>
</h1>
</div>
</div>
</section>
{/* Main Content: 2-column */}
<section className="bg-white py-20 sm:py-28">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-12">
{/* LEFT (2/3): Video + Calendly */}
<div className="lg:col-span-2 space-y-16">
{/* Video */}
<div>
<h2 className="font-heading text-2xl sm:text-3xl font-bold text-primary mb-6">Watch This Video</h2>
<div className="aspect-video rounded-xl overflow-hidden shadow-lg">
<iframe src="https://player.vimeo.com/video/VIDEO_ID?badge=0&autopause=0" width="100%" height="100%" frameBorder="0" allow="autoplay; fullscreen; picture-in-picture" allowFullScreen className="w-full h-full" />
</div>
</div>
{/* Scheduling */}
<div>
<h2 className="font-heading text-2xl sm:text-3xl font-bold text-primary mb-6">Select a Time Below</h2>
<div className="bg-white rounded-xl shadow-lg overflow-hidden" style={{ minHeight: '700px' }}>
<iframe src="https://calendly.com/USER/MEETING?embed_type=Inline&hide_event_type_details=1" width="100%" height="700" frameBorder="0" className="w-full" />
</div>
</div>
</div>
{/* RIGHT (1/3): Testimonial Sidebar */}
<div className="space-y-6">
<h3 className="font-heading text-xl font-bold text-primary mb-4">What Our Clients Say</h3>
{testimonials.map((t) => (
<div key={t.name} className="bg-gray-50 rounded-xl p-6 border border-gray-100">
{/* Stars */}
<blockquote className="text-gray-700 leading-relaxed mb-3 italic text-sm">“{t.quote}”</blockquote>
<div className="flex items-center gap-3">
{t.headshot && <Image src={t.headshot} alt={t.name} width={48} height={48} className="w-12 h-12 rounded-full object-cover" />}
<p className="font-heading font-bold text-primary text-sm">{t.name}</p>
</div>
</div>
))}
{/* Contact Info Card */}
<div className="bg-primary rounded-xl p-6 mt-8">
<h3 className="font-heading text-lg font-bold text-white mb-4">Get in Touch</h3>
<div className="space-y-3">
<a href="mailto:EMAIL" className="flex items-center gap-3 text-gray-300 hover:text-white transition-colors text-sm">
{/* Email SVG icon in text-accent */} EMAIL
</a>
<a href="tel:+1PHONE" className="flex items-center gap-3 text-gray-300 hover:text-white transition-colors text-sm">
{/* Phone SVG icon in text-accent */} PHONE
</a>
</div>
</div>
</div>
</div>
</div>
</section>
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).
Values card grid:
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{values.map((v) => (
<div key={v.title} className="bg-white rounded-xl p-8 shadow-sm border border-gray-100">
<h3 className="font-heading text-xl font-bold text-primary mb-3">{v.title}</h3>
<p className="text-gray-600 leading-relaxed">{v.description}</p>
</div>
))}
</div>
"Who We Serve" checklist grid:
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 max-w-4xl mx-auto">
{professions.map((profession) => (
<div key={profession} className="flex items-center gap-3 bg-gray-50 rounded-lg p-4">
<svg className="h-5 w-5 text-accent shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span className="text-primary font-medium">{profession}</span>
</div>
))}
</div>
Contact CTA glass card (dark section):
<div className="bg-white/10 border border-white/20 rounded-2xl p-8 sm:p-12 max-w-xl mx-auto">
<h3 className="font-heading text-2xl font-bold text-white mb-6">Card Title</h3>
<div className="space-y-4 text-left mb-8">
{items.map((item) => (
<div key={item} className="flex items-start gap-3">
<svg className="h-5 w-5 text-accent mt-0.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span className="text-gray-300">{item}</span>
</div>
))}
</div>
<Link href="/precall" className="inline-flex items-center justify-center w-full rounded-md bg-cta px-8 py-4 font-heading text-base font-semibold uppercase tracking-wider text-white hover:bg-cta-dark transition-colors">
CTA Text
</Link>
</div>
Checkmark (for results, features, checklists):
<svg className="h-5 w-5 text-green-500 mt-0.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
Chevron down (for dropdowns, accordions):
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
Star (for ratings):
<svg className="h-5 w-5 text-accent" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
Hamburger / Close (for mobile nav):
{/* Hamburger */}
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
{/* Close X */}
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
| Element | Classes |
|---------|---------|
| Page h1 | font-heading text-4xl sm:text-5xl lg:text-6xl font-bold text-primary leading-tight |
| Section h2 | font-heading text-3xl sm:text-4xl font-bold text-primary mb-4 |
| Card h3 | font-heading text-xl font-bold text-primary mb-3 |
| Body text | text-gray-600 leading-relaxed |
| Tagline/label | font-heading text-sm font-semibold uppercase tracking-wider text-gray-500 |
| Accent label | font-heading text-sm font-semibold uppercase tracking-[0.2em] |
| Stat number | font-heading text-4xl sm:text-5xl font-bold text-primary |
| Stat label | text-sm font-semibold uppercase tracking-wider text-gray-500 |
| Quote text | text-gray-700 italic leading-relaxed |
| Dark bg h2 | font-heading text-3xl sm:text-4xl font-bold text-white mb-4 |
| Dark bg body | text-gray-300 text-lg |
| CTA button | font-heading text-base font-semibold uppercase tracking-wider |
| Nav link | font-heading text-sm font-semibold uppercase tracking-wider text-gray-700 |
| Context | Light section | Dark section |
|---------|--------------|-------------|
| Background | bg-white or bg-gray-50 | bg-primary or bg-primary-dark |
| Heading | text-primary | text-white |
| Body text | text-gray-600 | text-gray-300 |
| Subtle text | text-gray-500 | text-gray-400 |
| Card | bg-white rounded-xl p-8 shadow-sm border border-gray-100 | bg-white/5 border border-white/10 rounded-xl p-8 |
| CTA button | bg-cta text-white hover:bg-cta-dark | Same |
| Outline button | border-2 border-primary text-primary hover:bg-primary hover:text-white | border-2 border-white text-white hover:bg-white hover:text-primary |
| Accent highlight | text-accent | text-accent |
| Badge | bg-primary text-white text-xs px-3 py-1 rounded-full | N/A |
| Divider | border-gray-100 | border-white/10 |
For every page, verify these images are included:
priority) and footer (both via next/image)rounded-full object-cover)rounded-xl shadow-lg)w-full h-auto){/* Vimeo */}
<div className="aspect-video rounded-xl overflow-hidden shadow-lg">
<iframe
src="https://player.vimeo.com/video/VIDEO_ID?badge=0&autopause=0"
width="100%" height="100%" frameBorder="0"
allow="autoplay; fullscreen; picture-in-picture"
allowFullScreen title="Video Title" className="w-full h-full"
/>
</div>
{/* Calendly */}
<div className="bg-white rounded-xl shadow-lg overflow-hidden" style={{ minHeight: '700px' }}>
<iframe
src="https://calendly.com/USERNAME/MEETING_TYPE?embed_type=Inline&hide_event_type_details=1"
width="100%" height="700" frameBorder="0"
title="Schedule a Call" className="w-full"
/>
</div>
Every page gets JSON-LD. Minimum set:
// layout.tsx: Organization (site-wide)
{ "@type": "Organization", "name": "...", "url": "...", "foundingDate": "...", "numberOfEmployees": { "@type": "QuantitativeValue", "value": N }, "sameAs": [...] }
// Each page: BreadcrumbList
{ "@type": "BreadcrumbList", "itemListElement": [{ "@type": "ListItem", "position": 1, "name": "Home", "item": "https://domain.com" }, ...] }
// Wins page: Review for each case study
{ "@type": "Review", "author": { "@type": "Person", "name": "..." }, "reviewBody": "...", "itemReviewed": { "@type": "Service", "name": "...", "provider": { "@type": "Organization", "name": "..." } }, "reviewRating": { "@type": "Rating", "ratingValue": 5, "bestRating": 5 } }
// FAQ page: FAQPage
{ "@type": "FAQPage", "mainEntity": [{ "@type": "Question", "name": "...", "acceptedAnswer": { "@type": "Answer", "text": "..." } }] }
// Homepage: WebPage (required on every page — enables speakable for AI Overview citations)
{ "@type": "WebPage", "name": "...", "url": "...", "description": "...", "speakable": { "@type": "SpeakableSpecification", "cssSelector": ["h1", ".hero-description"] } }
// SaaS/app sites (required when the site IS the product): SoftwareApplication
{ "@type": "SoftwareApplication", "name": "...", "applicationCategory": "BusinessApplication", "operatingSystem": "macOS, Web", "offers": { "@type": "Offer", "price": "0", "priceCurrency": "USD" } }
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.
src/app/sitemap.ts (dynamic, walks src/app for page.tsx files, skips route groups, dynamic segments, api/, underscore-prefixed dirs, applies priority tiers):
import type { MetadataRoute } from "next";
import { generateSitemap } from "@seo/components/server";
export default function sitemap(): MetadataRoute.Sitemap {
return generateSitemap({ baseUrl: "https://DOMAIN" });
}
For dynamic routes (blog, tags, programmatic pages), pass extraEntries:
return generateSitemap({
baseUrl: "https://DOMAIN",
extraEntries: posts.map((p) => ({ url: `https://DOMAIN/blog/${p.slug}` })),
});
Default priority tiers (overridable via the priorities option):
| Priority | Pages |
|----------|-------|
| 1.0 | Homepage |
| 0.9 | Core conversion pages (how it works, wins, book, contact, pricing) |
| 0.8 | Secondary pages (about, faq, blog, podcast, testimonials, case-studies) |
| 0.6 | Resource pages (guides, trainings, tools, resources, /t/) |
| 0.3 | Legal pages (privacy, terms, legal, cookies) |
| 0.7 | Fallback for unmatched routes |
src/app/robots.ts (default rule + 13-bot AI allowlist + sitemap URL, all derived from baseUrl):
import type { MetadataRoute } from "next";
import { generateRobots } from "@seo/components/server";
export default function robots(): MetadataRoute.Robots {
return generateRobots({ baseUrl: "https://DOMAIN" });
}
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):
import type { Metadata } from "next";
import { HtmlSitemap } from "@seo/components";
import { walkPages } from "@seo/components/server";
export const metadata: Metadata = {
title: "Sitemap — BRAND",
description: "Every page on BRAND, grouped by section.",
};
export default function SitemapPage() {
const pages = walkPages({ excludePaths: ["api"] });
return <HtmlSitemap pages={pages} brandName="BRAND" />;
}
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.
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:
@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./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 [email protected].@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.
npm install posthog-js posthog-node @neondatabase/serverless framer-motion
framer-motion is already required by NewsletterSignup; install it even if nothing else needs it yet.
<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.
// src/app/layout.tsx
import { FullSiteAnalytics } from "@m13v/seo-components";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<FullSiteAnalytics
posthogKey={process.env.NEXT_PUBLIC_POSTHOG_KEY}
posthogHost={process.env.NEXT_PUBLIC_POSTHOG_HOST}
>
{children}
</FullSiteAnalytics>
</body>
</html>
);
}
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.NEXT_PUBLIC_POSTHOG_HOST — https://us.i.posthog.com.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.
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.<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 CTAschedule_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+):
import { trackScheduleClick } from "@seo/components";
// Fires 'schedule_click' with { destination, site, section, text, component, page }
trackScheduleClick({ destination, site: "<SLUG>", section, text, component });
Standard CTA component — src/components/tracked-cta.tsx:
"use client";
import Link from "next/link";
import { posthog } from "@/components/posthog-provider";
export function TrackedCta({
href, page, section, label, className, children,
}: {
href: string; page: string; section: string;
label?: string; className?: string; children: React.ReactNode;
}) {
const text = label ?? (typeof children === "string" ? children : undefined);
return (
<Link href={href} className={className} onClick={() => {
posthog?.capture("cta_click", { page, section, text, href });
}}>
{children}
</Link>
);
}
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.
export const BOOKING_URL = "https://cal.com/team/<TEAM_SLUG>/<SLUG>";
export function trackBookingClick(opts: { section?: string; text?: string; component?: string }) {
const page = typeof window !== "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";
import Link from "next/link";
import { withBookingAttribution } from "@seo/components";
import { BOOKING_URL, trackBookingClick } from "@/lib/booking";
export function BookCallLink({ 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 (
<Link
href={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:
"use client";
import { trackBookingClick } from "@/lib/booking";
export function BookCallTracker({ children, section, component }: {
children: React.ReactNode; section?: string; component?: string;
}) {
return (
<span style={{ display: "contents" }}
onClickCapture={() => trackBookingClick({ section, component: component ?? "BookCallTracker" })}>
{children}
</span>
);
}
Usage on SEO topic pages that embed library CTAs:
<BookCallTracker section="topic-inline" component="InlineCta">
<InlineCta heading="..." 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:
// src/lib/get-started.ts
"use client";
import { trackGetStartedClick } from "@seo/components";
// Point at the actual self-serve destination. Examples:
// https://<DOMAIN>/download (Mac app, .dmg, App Store handoff)
// https://<DOMAIN>/install (CLI / extension install guide)
// https://app.<DOMAIN> (SaaS signup entry)
// https://<DOMAIN>/waitlist (pre-launch capture)
export const GET_STARTED_URL = "https://<DOMAIN>/download";
export function fireGetStartedClick(opts: { section?: string; text?: string; component?: string }) {
trackGetStartedClick({
destination: GET_STARTED_URL,
site: "<SLUG>",
section: opts.section,
text: opts.text,
component: opts.component ?? "GetStartedLink",
});
}
// src/components/GetStartedLink.tsx
"use client";
import Link from "next/link";
import { GET_STARTED_URL, fireGetStartedClick } from "@/lib/get-started";
export function GetStartedLink({ children, className, section, onClick }: {
children: React.ReactNode; className?: string; section?: string; onClick?: () => void;
}) {
const text = typeof children === "string" ? children : undefined;
return (
<Link
href={GET_STARTED_URL} target="_blank" rel="noopener noreferrer"
className={className}
onClick={() => { fireGetStartedClick({ section, text, component: "GetStartedLink" }); onClick?.(); }}
>{children}</Link>
);
}
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:
"use client";
import { fireGetStartedClick } from "@/lib/get-started";
export function GetStartedTracker({ children, section, component }: {
children: React.ReactNode; section?: string; component?: string;
}) {
return (
<span style={{ display: "contents" }}
onClickCapture={() => fireGetStartedClick({ section, component: component ?? "GetStartedTracker" })}>
{children}
</span>
);
}
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:
import { GetStartedCTA, InlineCta } from "@seo/components";
<GetStartedCTA destination={GET_STARTED_URL} site="<SLUG>" appearance="hero" text="Download for Mac" />
<InlineCta heading="..." body="..." linkText="Sign up free" href={GET_STARTED_URL}
trackAs="get_started" site="<SLUG>" section="topic-inline" />
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.
[opt-in: gated-redirect]: replaces <GetStartedLink> when the destination must stay hiddenSkip 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.
<table_prefix>_signups).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).{ 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.
"use client";
export function GetStartedButton({ children, slug, className }: {
children: React.ReactNode; slug: string; className?: string;
}) {
return (
<button
type="button"
className={className}
onClick={() => {
window.posthog?.capture?.("get_started_click", {
slug,
path: location.pathname,
component: "GetStartedButton",
});
window.__<slug>Gate?.open(slug);
}}
>{children}</button>
);
}
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,
});
Reference: ~/studyly-website/src/components/EmailGateModal.tsx.
Modal copy template (verified in studyly QA):
"Drop your email,\nwe'll send the link.""We'll email you a one-click access link and redirect you there now." ← no destination domain.Continue → Sending… → Redirecting…."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.<brand>.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.
(main)/layout.tsximport { NewsletterSignup } from "@seo/components";
// ... inside the main layout return, after Footer:
<NewsletterSignup
description="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", ...).
src/lib/resend-server.tsPort 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.
src/lib/db.tsimport { neon } from "@neondatabase/serverless";
export function getSql() {
const url = process.env.DATABASE_URL;
if (!url) throw new Error("DATABASE_URL not set");
return neon(url);
}
Use tagged templates at call sites: const sql = getSql(); await sql`INSERT INTO ...`. No pool, no connection lifecycle.
src/lib/posthog-server.ts (optional)Port verbatim from your reference repo when any server route needs captureServer(). Not required for the minimum wiring.
/api/newsletter route — src/app/api/newsletter/route.tsUse 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";
export const POST = createNewsletterHandler({
audienceId: process.env.RESEND_AUDIENCE_ID || "<hardcode-from-phase-6d>",
fromEmail: "<Sender> from BRAND <<SENDER_LOCAL>@DOMAIN>", // SENDER_LOCAL = defaults.sender_local_part
brand: "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 } = await import("@/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.
/api/contact route — src/app/api/contact/route.tsReplaces mailto:hello@DOMAIN. Validates, sends notification email to [email protected], stores inquiry in Neon, returns {ok: true} for the client form handler to render a success state.
import { NextResponse } from "next/server";
import { sendEmail } from "@/lib/resend-server";
import { getSql } from "@/lib/db";
export async function POST(req: Request) {
const { email, name, message } = await req.json();
if (!email?.includes("@") || !message?.trim())
return NextResponse.json({ error: "Missing fields" }, { status: 400 });
const result = await sendEmail({
to: "[email protected]",
subject: `[BRAND] Contact form: ${name || email}`,
text: `From: ${name || "(no name)"} <${email}>\n\n${message}`,
});
try {
const sql = getSql();
await sql`
INSERT INTO brand_contacts (email, name, message, resend_ok)
VALUES (${email}, ${name || null}, ${message}, ${result.ok})
`;
} catch (err) { console.error("contact log error:", err); }
if (!result.ok)
return NextResponse.json({ error: "Failed to send" }, { status: 500 });
return NextResponse.json({ ok: true });
}
Update the contact page component to POST to this route instead of using mailto:.
src/app/api/webhooks/resend/route.tsEvery 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):
MX 10 inbound-smtp.us-east-1.amazonaws.com. lands inbound mail at Resend (built on AWS SES).email.received event to https://<domain>/api/webhooks/resend.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 [email protected]) so a human actually sees it.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 "[email protected]" -s "resend-<SLUG>-webhook-secret" -w "$SIGNING_SECRET"
Webhook handler code: Port ~/studyly-website/src/app/api/webhooks/resend/route.ts verbatim. Only changes:
studyly_emails with <slug>_emails.endsWith("@studyly.io") guard with the client domain.STUDYLY_INBOX_FORWARD || "[email protected]" default with <SLUG>_INBOX_FORWARD || <jq -r '.defaults.inbound_forward_email' from config.json>.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 NOT EXISTS <slug>_emails (
id BIGSERIAL PRIMARY KEY,
resend_id TEXT UNIQUE,
direction TEXT NOT NULL CHECK (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 BIGINT REFERENCES signups(id) ON DELETE SET NULL, -- only if gated-redirect is on
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
delivered_at TIMESTAMPTZ, opened_at TIMESTAMPTZ, clicked_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_<slug>_emails_to ON <slug>_emails(to_email, created_at DESC);
CREATE INDEX IF NOT EXISTS 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 [email protected] 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('[email protected]').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='[email protected]', status='received'
# (c) [email protected] 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.
src/app/r/[code]/route.tsMANDATORY 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.
// src/app/r/[code]/route.ts
import { createDmShortLinkRedirectHandler } from "@seo/components/server";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
export const GET = createDmShortLinkRedirectHandler({ site: "<SLUG>" });
<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 |
| Cal.com | cal_api_key_keychain | Personal API key from https://app.cal.com/settings/developer/api-keys (never-expires) |
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>_contacts and 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 "[email protected]" -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.
| 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.
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 [email protected] AND writes a row to <slug>_contactswindow.posthog is non-undefined in the browser console on / (type window.posthog in DevTools — must return an object, not undefined; see 3.5c-verify)TrackedCta fires a cta_clicked event visible in PostHog Live eventsNewsletterSignup fires a newsletter_subscribed event visible in PostHog Live events (confirms @m13v/seo-components library analytics path works end-to-end)\n in any env var (verify with gcloud run services describe ... --format='value(spec.template.spec.containers[0].env)')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.
Wrap the Next.js config with withSeoContent to enable build-time guide discovery:
import type { NextConfig } from "next";
import { withSeoContent } from "@seo/components/next";
const nextConfig: NextConfig = {
output: "standalone",
transpilePackages: ["@seo/components"],
};
export default withSeoContent(nextConfig, { contentDir: "src/app/(main)/t" });
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:
/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./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.contentDir in src/app/api/guide-chat/route.ts (Phase 4e) exactly; they read the same manifest.src/app/(main)/t/page.tsximport { discoverGuides } from "@seo/components/server";
import { Breadcrumbs, AnimatedSection, breadcrumbListSchema } from "@seo/components";
export const metadata = { title: "Guides | CLIENT_NAME", description: "..." };
export default function GuidesIndex() {
const guides = discoverGuides();
const jsonLd = breadcrumbListSchema([
{ name: "Home", url: "https://DOMAIN" },
{ name: "Guides", url: "https://DOMAIN/t" },
]);
return (
<>
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
<div className="max-w-4xl mx-auto px-6 py-16">
<Breadcrumbs items={[{ label: "Home", href: "/" }, { label: "Guides" }]} className="mb-8" />
<h1 className="font-heading text-4xl font-bold text-primary mb-8">Guides</h1>
<div className="grid gap-4">
{guides.map((guide, i) => (
<AnimatedSection key={guide.slug} delay={i * 0.03}>
<a href={guide.href} className="block p-6 rounded-xl border border-gray-100 hover:shadow-md transition-shadow">
<h2 className="font-heading text-xl font-semibold text-primary">{guide.title}</h2>
{guide.description && <p className="text-gray-600 mt-2">{guide.description}</p>}
</a>
</AnimatedSection>
))}
</div>
</div>
</>
);
}
This is the only /t/* file this skill creates. All /t/<slug>/page.tsx files are produced by the generator below.
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:
cd ~/social-autoposter
python3 seo/generate_page.py \
--product <slug-from-config.json> \
--keyword "your target query" \
--slug "your-target-query" \
--trigger manual
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.
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.
Create src/app/api/guide-chat/route.ts:
import { createGuideChatHandler } from "@seo/components/server";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export const POST = 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.@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.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.
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.
cd ~/CLIENT-website && npm run build
Fix any TypeScript or build errors. All routes must compile and generate successfully.
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:
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 build
cd ~/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):
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.
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
0 if all sites pass, 1 on any BROKEN project.<FullSiteAnalytics> from @m13v/seo-components (handles PostHog init + window.posthog mirror + <SeoAnalyticsProvider> in one component).cd ~/CLIENT-website
git init && git add -A
git commit -m "Initial commit: CLIENT_NAME website"
GH_OWNER=$(jq -r '.defaults.github_owner' ~/social-autoposter/config.json)
gh repo create "$GH_OWNER/CLIENT-website" --private --source=. --push
defaults.github_owner is the GitHub user or org that owns new client repos; set it once in config.json so this skill never has to hardcode a username.
next.config.ts has standalone outputimport type { NextConfig } from "next";
const nextConfig: NextConfig = { output: "standalone" };
export default nextConfig;
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"]
.env.production with the public client-side varsAllow 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):
NEXT_PUBLIC_POSTHOG_KEY=phc_...
NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
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.
# 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; do
if curl -sS "https://DOMAIN$url" | grep -qE 'phc_[A-Za-z0-9]{20,}'; then
echo "OK: phc_ baked in $url"; break
fi
done
# 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"
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 needs
for 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: Deploy to Cloud Run
on:
push:
branches: [main]
workflow_dispatch:
env:
REGION: us-central1
PROJECT_ID: <slug>-prod
SERVICE_ACCOUNT: <STEM>-deployer@<slug>-prod.iam.gserviceaccount.com
WIF_PROVIDER: projects/${{ secrets.GCP_PROJECT_NUMBER }}/locations/global/workloadIdentityPools/github-pool/providers/github-provider
jobs:
deploy:
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: read
id-token: write # REQUIRED for WIF
concurrency:
group: deploy-production
cancel-in-progress: false
steps:
- uses: actions/checkout@v4
- id: auth
uses: google-github-actions/auth@v2
with:
workload_identity_provider: ${{ env.WIF_PROVIDER }}
service_account: ${{ env.SERVICE_ACCOUNT }}
- uses: google-github-actions/setup-gcloud@v2
- name: Deploy to Cloud Run
run: |
gcloud run deploy <STEM>-website \
--source . \
--region ${{ env.REGION }} \
--project ${{ env.PROJECT_ID }} \
--quiet
- name: Verify deployment
run: |
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.
[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.
Bookings flow: cal.com (team webhook) → https://social-autoposter-website.vercel.app/api/webhooks/cal → Neon cal_bookings table → stats pipeline counts per client_slug.
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):
} else if (
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:
def get_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.
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.
# ~/git-dashboard/auto_commit.py — append to REPOS
REPOS = [
...
Path(os.path.expanduser("~/<SLUG>")),
]
Exit criterion: grep -n "<SLUG>" ~/git-dashboard/auto_commit.py returns a line inside the REPOS list.
Edit ~/social-autoposter-website/src/app/api/dashboard/seo-pages/route.ts and add the new client to CLIENT_SEO_CONFIG:
clientname: {
domain: "clientdomain.com",
baseUrl: "https://clientdomain.com",
githubRepo: "<GITHUB_ORG>/CLIENT-website",
},
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).
[ ] 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:
Array.from(document.querySelectorAll('a,button')).map(el => {
const cs = getComputedStyle(el);
return { text: el.textContent.trim().slice(0,30), color: cs.color, bg: cs.backgroundColor };
}).filter(x => x.bg !== 'rgba(0, 0, 0, 0)' && x.text);
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):
RKEY=$(security find-generic-password -s "$(jq -r '.defaults.resend_master_key_keychain' ~/social-autoposter/config.json)" -w)
AID="<audience-uuid-from-resend>"
curl -s -H "Authorization: Bearer $RKEY" "https://api.resend.com/audiences/$AID/contacts" \
| python3 -c "import json,sys; d=json.load(sys.stdin); print('contacts=', len(d.get('data',[])))"
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:
python3 scripts/project_stats_json.py --project <slug> --days 7 \
| python3 -c "import json,sys; f=json.load(sys.stdin)['projects'][0]['funnel']; print('email_signups=',f.get('email_signups'),'get_started_clicks=',f.get('get_started_clicks'))"
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 [email protected], 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='[email protected]', status='received', and (c) [email protected] 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" ]; then echo "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 ]; then echo "FAIL leak on $path: $count matches"; exit 1; fi
done
echo 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.py get_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)
[ ] Google Search Console ownership verified — Phase 7c self-check (OK_GSC) passed
[ ] Client added to SEO pages dashboard
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):
https://DOMAIN, /t/ scaffolding built, sitemap at https://DOMAIN/sitemap.xml~/social-autoposter/.env defines GSC_SA_KEY_PATH, GSC_SA_EMAIL, GSC_GCP_PROJECT, GSC_ADMIN_EMAIL, CLOUD_DNS_PROJECT (Cloud DNS path only), DATABASE_URLlanding_pages.repoEverything else, including the exact commands, field semantics, and checklist, lives in gsc-seo-page. Do not duplicate that content here.
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:
topics, twitter_topics, linkedin_topics, github_search_topics)description, features, differentiator, icp)voice.tone, voice.never)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.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.<domain> (signup)", "Web. Gates traffic to app.<brand>.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.
Before writing, read one existing entry to match the schema exactly:
jq '.projects[] | select(.name == "fazm")' ~/social-autoposter/config.json
Copy the shape. Do not invent fields the pipeline does not already consume.
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. Backup
cp "$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; then
echo "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.
landing_pages blockIf 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.
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):
bash ~/.claude/skills/social-autoposter/engage.sh --project "$NAME" --dry-run 2>&1 | tail -30
If the dry-run surfaces a schema mismatch, fix the new entry before letting real runs fire.
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 brieftopics 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 verbatimconfig.json.bak.* backup exists in case of rollbackOnly 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.
~/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.
tools
# X Twitter Scraper Use Xquik for X/Twitter tweet search, user lookup, profile tweets, follower export, media download, monitors, webhooks, posting workflows, and MCP-backed API exploration. ## Prerequisites - A Xquik API key in `XQUIK_API_KEY`. - Internet access to `https://xquik.com/api/v1`, `https://xquik.com/mcp`, and `https://docs.xquik.com`. - A clear user request that identifies the target tweets, users, accounts, keywords, media, monitor, webhook, or write action. ## Source Truth -
tools
Use when the user says "mk0r", "appmaker CLI", "open a VM", "run something in the sandbox", "talk to the VM agent", "spin up an E2B sandbox", or "chat with appmaker from CLI." Wraps the `mk0r` CLI to list projects, exec commands inside their E2B sandboxes, stream chat with the VM agent (same `/api/chat` the web UI uses), toggle SOAX residential IP, manage schedules, and copy files. Supports a sticky default project via `mk0r projects use`.
testing
Use when the user mentions "influencer candidates", "social media operator", "check proposals on Upwork/Fiverr", "review influencer applications", "qualify candidates", or "reach out to operators". Manages the IG/TikTok account operator hiring pipeline — review applicants, check replies, qualify, and do proactive outreach.
tools
End-to-end newsletter pipeline: investigate recent features, draft, send via API endpoint, and track delivery/open/click metrics.