// One-time onboarding for a new consumer site: SEO audit of the existing site, Next.js 16 scaffold, @m13v/seo-components install, route-group architecture, real images/video/structured data, Cloud Run deploy, dashboard registration, and SEO infrastructure wiring (withSeoContent, guide index, optional sidebar and AI chat). This skill stops at infrastructure; day-to-day SEO guide page generation is handled by the gsc-seo-page skill and seo/generate_page.py. Use when: 'set up client website', 'onboard new client site', 'new consumer site', 'recreate website', 'rebuild website', or when spinning up a fresh site that will later receive programmatic SEO pages.
One-time onboarding for a new consumer site: SEO audit of the existing site, Next.js 16 scaffold, @m13v/seo-components install, route-group architecture, real images/video/structured data, Cloud Run deploy, dashboard registration, and SEO infrastructure wiring (withSeoContent, guide index, optional sidebar and AI chat). This skill stops at infrastructure; day-to-day SEO guide page generation is handled by the gsc-seo-page skill and seo/generate_page.py. Use when: 'set up client website', 'onboard new client site', 'new consumer site', 'recreate website', 'rebuild website', or when spinning up a fresh site that will later receive programmatic SEO pages.
user_invocable
true
Setup Client Website
One-time onboarding flow for a new (or rebuilt) client/consumer site. Produces a modern, SEO-optimized Next.js site with real content, images, video embeds, structured data, and the infrastructure needed for programmatic SEO guide pages.
Scope boundary: this skill leaves the repo in a state where /t/<slug> guide pages can be produced. It does NOT write guide pages. All guide-page content generation is handled by the gsc-seo-page skill, which drives ~/social-autoposter/seo/generate_page.py. After running this skill, register the site in ~/social-autoposter/config.json and hand off to gsc-seo-page for every page.
Arguments
Provide the client name, domain (if any), and existing site URL (if any). Example: "Paperback Expert at paperbackexpert.com"
Optional scope flags
These are OFF by default. Only enable them if the invoker mentions the feature explicitly (e.g. "with book-a-call", "add a contact form"). If the scope flag is not mentioned, skip every phase marked [opt-in: book-a-call] etc. — do not half-scaffold placeholders.
Flag
Default
Triggers phases
book-a-call
off
3.5d Book-a-Call helpers, 3.5l Cal.com event type creation, 6g Cal.com webhook wiring, Phase 8 booking-verification row, Phase 10a booking_link field
When a flag is off: do not add <BookCallLink>/<BookCallTracker>, do not create a Cal.com event type, do not add booking_link to config.json, and skip the booking-related Phase 8 checklist rows. The DM / dashboard pipeline must fall back to links.install (or whatever primary CTA the site has) when booking_link is absent.
Rationale: free OSS tools, install-driven products, and open-source landing pages (ClaudeMeter, appmaker-style utilities) have no "book a call" conversion. Forcing Cal.com wiring on them created dead links + placeholder config that broke the stats pipeline.
Prerequisites
Google Cloud project under the m13v.com org (or create a new one)
GitHub org or personal account
PostHog account (org: m13v) for analytics
Resend account (you@example.com) for transactional email
Neon account for Postgres (one project per client, pooled connection)
Google Search Console access
Isolated browser MCP for visual comparison
Stack
Next.js 16 (App Router) + React 19 + TypeScript
Tailwind CSS 4 (inline theme via @theme)
next/image for optimized images
Google Cloud Run for hosting (with HTTPS Load Balancer + Certificate Manager)
PostHog for analytics (pageviews, CTA clicks, newsletter subscribes)
Neon (@neondatabase/serverless) for email + contact logs
Phase 1: Audit and Research
Phase 1 runs two tracks concurrently:
Outward track (1a): understand the market the client sells into (competitors, search demand, industry developments, ICP).
Inward track (1b-1e): audit what the client already has (SEO baseline, content crawl, assets, screenshots).
Both tracks feed 1f, which produces a single research-brief.md that every downstream phase (copy, hero, CTAs, FAQ, case studies) is required to consume. Research that does not make it into the brief is decoration.
1a. Market Research Fan-Out
Launch these agents in parallel. They have no dependencies on each other or on the inward track, so batch them in a single message.
Launch 4 agents in parallel:
- competitor-analysis: identify top 3-5 rivals by SERP + brand search.
Per rival, capture: positioning one-liner, pricing, primary CTAs,
hero copy, testimonial themes, messaging pillars, obvious gaps or
weaknesses.
- keyword-research + serp-analysis (single agent, both skills): head
terms, long-tail clusters, search intent (informational /
commercial / transactional), SERP feature mix (AI Overviews, PAA,
video, local pack), difficulty, monthly volume.
- deep-research-pro: industry developments in the last 90 days,
regulation, notable launches, funding, M&A, price moves, new
entrants, platform / distribution shifts. Cite sources.
- general-purpose (ICP pass, with WebFetch): 1-2 primary personas with
jobs-to-be-done, top 3 pains, top 3 gains, objections, triggers,
and the language they actually use (verbatim pulls from Reddit
threads, review sites, forum posts, NOT marketing copy).
Output: four raw reports in research/raw/ (competitors.md, keywords.md, industry.md, icp.md). Do not edit them down here, 1f does the compression.
Budget guardrail: if any single agent returns more than ~15k tokens, ask it to re-emit a tighter version capped at ~8k before moving on. Raw-output bloat is the main failure mode of this step.
1b. SEO Audit (if existing site)
Run parallel SEO agents to baseline the current site. This runs concurrently with 1a.
Launch 5 agents in parallel:
- seo-technical: crawlability, indexability, Core Web Vitals, mobile
- seo-content: E-E-A-T signals, readability, content depth
- seo-schema: existing structured data (JSON-LD, Microdata, RDFa)
- seo-performance: Lighthouse scores, LCP, CLS, TBT (desktop + mobile)
- seo-geo: AI crawler accessibility, llms.txt, citation readiness
Record all scores. These become the "before" baseline and the fix list for the new site.
1c. Crawl All Pages
Use an agent with WebFetch to discover and extract content from every page on the site:
Fetch the homepage, extract all navigation and footer links
Full body text (quotes, testimonials, stats, descriptions)
CTA text and link targets
Form fields (if any)
Navigation links (to discover more pages)
Output: Complete content inventory organized by page.
1d. Extract Visual Assets (and Brand Identity)
Use the isolated browser to catalog images, videos, and embeds on the original brand domain (e.g. piastech.com, cyrano.ai), not on the new generic SEO domain. The brand identity is the property of the brand domain.
// In isolated browser, navigate to each page and run:
() => {
const imgs = Array.from(document.querySelectorAll('img')).map((img, i) => {
const rect = img.getBoundingClientRect();
return { idx: i, src: img.src, y: Math.round(rect.y), w: Math.round(rect.width), h: Math.round(rect.height) };
}).filter(x => x.w > 50);
const iframes = Array.from(document.querySelectorAll('iframe')).map(f => f.src);
return { imgs, iframes };
}
Key assets to identify and download:
Brand wordmark / logo (header top-left; may be SVG, PNG, or inline text — capture exact spelling, casing, and any logomark character like π(a|s))
Favicon (/favicon.ico, /favicon.png, or any <link rel="icon"> href)
Hero images or background photos
Client/team headshot photos (circular, 100-200px)
Product images (book covers, screenshots, etc.)
Social proof imagery (awards, certifications, partner logos)
Video embeds (Vimeo, YouTube URLs)
Scheduling widgets (Calendly, Cal.com URLs)
Book cover strips / product galleries
Brand colors and fonts. Dump the brand site's CSS custom properties and fonts so the new site can match the palette:
// Run on the brand domain's homepage:
() => {
const r = getComputedStyle(document.documentElement);
const b = getComputedStyle(document.body);
const varNames = ['--background','--foreground','--primary','--primary-foreground','--accent','--card','--secondary','--muted','--border'];
const vars = Object.fromEntries(varNames.map(n => [n, r.getPropertyValue(n).trim()]));
return {
bodyBg: b.backgroundColor,
bodyFg: b.color,
bodyFont: b.fontFamily,
vars,
};
}
Also pull the brand site's CSS file and grep for hex/hsl color values if CSS vars aren't exposed:
Save the palette to research/brand-identity.md with: brand name exact spelling, logomark character (if any), primary/accent hex codes, body/heading fonts, favicon URL, and the logo image URL.
Download all identified images to public/images/ with descriptive filenames. Copy the favicon to public/favicon.png and convert to src/app/favicon.ico (multi-size .ico) with:
Capture full-page screenshots of every key page on the original site for visual reference:
For each page:
1. browser_navigate to URL
2. browser_take_screenshot with fullPage: true
3. Save as original-{pagename}-full.png
1f. Synthesize Research Brief
Once 1a-1e are all complete, run a single synthesizer pass (general-purpose agent, no fan-out) that reads every file in research/raw/ plus the crawl inventory and produces research/research-brief.md using exactly this schema:
# Research Brief: <client>## Positioning angle (one sentence)<singlecrispsentence — whattheclientuniquelyis, forwhom, againstwhom>## 3 differentiators1.<differentiator> — proof: <citation / source / datapoint>2. ...
3. ...
## 5 messaging pillars1.<pillarheadline> — supporting evidence: <...>
2. ...
...
## ICP (1-2 personas)
For each persona:
- Name + one-line description
- Top 3 jobs-to-be-done
- Top 3 pains (verbatim language from research)
- Top 3 gains
- Primary objection + the counter
- Trigger event that starts the buying journey
## Proof points
Verifiable stats, awards, case-study numbers, named clients, certifications, press mentions. Anything that can appear on the site as evidence — with source.
## Competitor landscape (one paragraph)
Who the client is up against, how they're positioned, and the gap the client is walking into.
## Banned clichés
Phrases pulled from competitor copy that the new site must NOT reuse (e.g. "cutting-edge", "world-class", "one-stop-shop", plus any industry-specific filler found during 1a).
## Industry signals (last 90 days)
3-5 bullets. Only items that should change hero copy, FAQ, or CTAs. Everything else is cut.
Hard rules for the synthesizer:
Every claim must trace to a file in research/raw/ or the crawl inventory. No invented stats.
Hero copy, CTAs, FAQ answers, and case-study selection in Phases 3+ must cite lines from this brief. If a later phase wants to say something not in the brief, it returns to 1a instead of making it up.
The brief is the artifact. The raw reports in research/raw/ are scratch and can be deleted after 1f lands.
1g. Visual Reference Brief (required for Phase 3)
Phase 3 no longer prescribes how each section looks. It prescribes structure and tokens; visual treatment comes from this brief. Without research/visual-references.md, Phase 3 cannot start.
Pick 3-5 reference sites:
2 direct category competitors (the best-designed ones from Phase 1a, not the most prominent).
2-3 design benchmarks from adjacent categories. Rotate so no two client sites share the same benchmark set. Pool: Linear, Raycast, Vercel, Anthropic, PostHog, Stripe, Resend, Rauno, Framer, Arc, Readwise, Granola, NYT Cooking, magazine editorial layouts (Apple newsroom, Stripe Press), brutalist/Swiss sites.
For each reference, capture in research/visual-references.md:
Screenshot at research/visual-references/<slug>.png (full page via isolated browser).
Palette (3-5 hex codes; note which is background, ink, accent, signal).
Type stack (display, body, mono families and weights).
2-3 distinctive motifs (e.g. section numbering with mono chip, live animated product mock in hero, paper grain overlay, hairline grid instead of cards, pull quotes with giant serif marks, floating persistent chip, scroll-reveal stagger, italic serif for emphasis, tilt-on-hover, copy-to-clipboard CTA, kinetic type).
One-line observation: what this reference does well that this client can borrow.
Close with a design thesis (one paragraph): the visual identity for this client site. Must be specific enough that two agents reading it would produce the same palette family and the same 2-3 signature motifs. Example: "Editorial, Swiss, on paper-cream #F4EEE4 with ink #121110 and signal orange #E8471C. Serif display (Instrument Serif, italic for emphasis), sans body (Geist), mono for meta and numbers (Geist Mono). Signature motifs: numbered section eyebrows with hairline rules, live-ticking product mock as the hero anchor, copy-to-clipboard install chip as primary CTA, pull quotes with 90px orange serif mark, floating persistent product chip that follows scroll."
Exemplar to read first:~/.claude/skills/setup-client-website/exemplars/claude-meter-editorial.html. This is ONE treatment (editorial, Swiss, paper-and-ink, product-first). Do not copy it. Read it as proof that Phase 3 output can be distinctive, product-first, and tactile, rather than the corporate baseline in Appendix A. Add a second and third exemplar to exemplars/ over time so this skill accumulates visual range.
Phase 1.5: Generic Domain Discovery and Purchase
Goal: secure a generic, keyword-rich domain that describes the product category rather than the brand (pattern: Cyrano → apartment-security-cameras.com). The brand domain (e.g. cyrano.ai) still exists; the generic domain is what the new Next.js site ships under for SEO.
Skip this phase only if the user explicitly opts out or the brand domain IS already the generic descriptive domain.
1.5a. Brainstorm candidates
Launch one subagent to generate two tracks of candidates, 15-25 per track. Both tracks describe the product category and never include the brand name.
Track A: Short brandable names (priority). These are the preferred output.
Length cap: under 8 characters for the stem (TLD excluded). Total domain with TLD should stay ≤ 11 chars where possible. Examples: s4l.ai (stem 3, total 6), fazm.ai (stem 4, total 7), mk0r.com (stem 4, total 8), fde10x.com (stem 6, total 10). Shorter stems are better.
Derive from the product category keyword by vowel drops, consonant clusters, or playful contractions (e.g. camera → cam, cmra, kmr; security → sec, scrty).
Digit substitution for availability. Swap letters for visually or phonetically similar digits to find available domains: o ↔ 0, i/l ↔ 1, e ↔ 3, a ↔ 4, s ↔ 5, b ↔ 6 or 8, t ↔ 7, g/q ↔ 9. Pattern matches the user's own brands: s4l.ai, mk0r.com, cl0ne.ai, t8r.tech, fde10x.com.
TLDs ordered by preference: .ai (short, premium feel), .io, .com, .co, short-country TLDs like .bz, .me, .to.
Must still phonetically suggest the category (not random letter soup).
Track B: Keyword-rich descriptive names (fallback). Use when Track A returns nothing available under the price cap.
3-6 words max; hyphens allowed.
Keyword-front-loaded (e.g. apartment-security-cameras.com, not best.cameras.for.apartments.com).
Mix TLDs: .com (preferred), .io, .ai, plus any niche TLD that fits the category (e.g. .cameras, .tech, .studio).
Seed the subagent with the Phase 1 research brief (research/brief.md) and the product's top 3 SEO keywords.
Output: research/domain-candidates.md with Track A on top, Track B below. One domain per line, grouped by track.
1.5b. Bulk discovery via Cloud Domains search
Run Google Cloud Domains' keyword search against the top 3 SEO keywords from the research brief. This adds Google's own suggestions on top of the subagent list:
This returns live availability and price (unlike search-domains which is cached). Parse availability (must be AVAILABLE) and yearlyPrice.units + yearlyPrice.currencyCode.
Run candidates in parallel with xargs -P 5 or a bash loop. Build a table of available domains and first-year price. Filter:
Drop any domain where availability != AVAILABLE.
Default price cap: $50/yr. Anything above goes in a separate "premium" list.
If get-register-parameters returns TLD_NOT_SUPPORTED for a candidate, move it to the Vercel fallback list (see 1.5e).
1.5d. Present top picks to the user
Show the user a ranked short-list (5-10 entries) with columns: domain, length (chars incl. TLD), price/yr, tld, registrar (Cloud Domains vs Vercel fallback), track (A=short, B=keyword-rich), rationale.
Ranking rules:
Track A candidates under 8 characters rank above Track B.
Within Track A, ties broken by: .ai > .io > .com > others.
Within Track B, .com beats everything else; ties broken by shorter length.
Always include at least one .com if any .com is available across either track.
Wait for the user to pick one. Never pre-select. Never auto-buy.
1.5e. Purchase via Cloud Domains (primary path)
Preconditions:
Contact YAML (one-time setup per user, reusable across purchases). If ~/.config/gcloud/domain-contacts.yaml does not exist, create it with the user's WHOIS contact info and chmod 600 it. Template:
allContacts:email:you@example.comphoneNumber:'+1.XXXXXXXXXX'postalAddress:regionCode:USpostalCode:'XXXXX'administrativeArea:CAlocality:SanFranciscoaddressLines: ['123 Example St']
recipients: ['Your Name']
Ask the user for the phone/address the first time; reuse thereafter.
GCP project: REUSE the shared social-autoposter project. Do NOT create a new project per client. The default shared project is piastest (confirmed via the fde10x.com purchase, 2026-04-17). All client sites, domains, DNS zones, and Cloud Run services live here. Only create a new project if the user explicitly overrides.
Before running any gcloud command in this phase, verify the active project:
gcloud config set project piastest
gcloud config get-value project # must print "piastest"
Known org-policy constraint on piastest:iam.disableServiceAccountKeyCreation is enforced, which blocks gcloud iam service-accounts keys create. Phase 6 CI/CD must use Workload Identity Federation, not SA keys. See Phase 6 for the WIF binding pattern.
Cloud DNS zone created in the shared project. Name the zone after the domain stem so zones from different clients don't collide:
gcloud dns managed-zones create <STEM>-zone --dns-name="<domain>." --project=piastest
(Example: fde10x.com → fde10x-zone.)
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.
When in doubt, run --validate-only first with private-contact-data. If it errors, parse the allowed values from contactSettings.privacy in the error output and retry with the correct flag.
Dry-run / preview first with --validate-only:
gcloud domains registrations register <domain> \
--project=piastest \
--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: piastest. 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=piastest should show the new registration in state ACTIVE (may take up to 5 minutes; REGISTRATION_PENDING is normal during that window).
Domain purchases are irreversible. Per the global Ethics Check rule, step 3 is mandatory.
1.5f. Purchase via Vercel (fallback path only)
Use this path ONLY when Cloud Domains returned TLD_NOT_SUPPORTED for the user's pick (e.g. niche TLDs like .cameras, .studio). In that case:
Use playwright-extension (real Chrome, attached to the user's logged-in session).
Navigate to https://vercel.com/matt-mediarais-projects/~/domains.
Enter the chosen domain. Wait for the dashboard's price + renewal preview.
Pause and ask the user to confirm. Print: About to buy <domain> for $<price> via Vercel (Cloud Domains doesn't support this TLD). Confirm? and wait for an explicit yes.
On yes, click "Buy" and wait for the success state.
Verify: vercel domains ls | grep <domain>.
DNS wire-up: in this fallback path, DNS lives in Vercel (not Cloud DNS). Phase 6 must add an A record in Vercel pointing to the Cloud Run Load Balancer's static IP instead of using gcloud dns record-sets.
Past incident (skl.bz, 2026-03-01): the card on file was declined and the CLI would have given no preview. Dashboard is mandatory here.
1.5g. Record in config.json
Add the project entry to ~/social-autoposter/config.json. The full shape is owned by the downstream writer (with weight, topics, twitter_topics, linkedin_topics, landing_pages, voice, icp, features, differentiator, etc.). The two fields Phase 1.5 is responsible for:
{"name":"<stem>","website":"https://<generic-domain>","brand_domain":"https://<brand-domain>",
...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).
Do NOT add registrar, gcp_project, or dns_zone fields. They are derivable: gcp_project is always piastest, DNS backend is Cloud DNS when the domain was bought via Cloud Domains and Vercel DNS only in the Vercel fallback path (rare). Downstream scripts don't read these fields.
If the fallback Vercel path was used, note it in the session summary; Phase 6 will branch on the registrar at deploy time by probing (gcloud domains registrations describe succeeds = Cloud Domains, else Vercel).
1.5h. Hand off to Phase 2
Phase 2 scaffolds under ~/<stem>-website/ (derive the slug from the purchased domain's stem, e.g. fde10x.com → ~/fde10x-website/). Phase 5 deploy and Phase 6 DNS wire-up use the generic domain as the primary target.
1.5i. Brand identity persists (the generic domain is SEO-only)
Hard rule: the generic domain is a URL, not a rebrand. When Phase 1.5 buys a keyword-rich domain (e.g. fde10x.com, apartment-security-cameras.com), that domain exists purely to rank on SEO keywords. The client's original brand is untouched and must be carried into the new site.
Carries over from the brand domain
Stays as the generic domain
Brand name (exact spelling, casing, any logomark character)
metadataBase, OpenGraph url, canonical URLs
Brand logo + favicon
Support email addresses (hello@<generic>.com)
Primary + accent colors
DB table names, internal slugs, PostHog site ID, env var names
Heading + body font pairing
GSC property, robots.txt, sitemap URL
Tagline / positioning (from the brand site hero)
Cloud Run service name, GCP project slug
Phase 3 (copy + design system), Phase 3a (Header), Phase 3b (Footer), Phase 3c (metadata + JSON-LD), and Phase 3.5 (email copy, PostHog name) must render the brand name from research/brand-identity.md, not the generic-domain stem. Email and URL strings keep the generic domain.
If the brand name and generic-domain stem happen to be the same (e.g. a client whose brand is literally FDE10x), this rule is a no-op. In every other case, use the brand name from research/brand-identity.md for every user-facing string and reserve the generic domain for technical identifiers.
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";
: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);
}
body {
background: var(--background);
color: var(--foreground);
}
html {
scroll-behavior: smooth;
}
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.
2d. Configure Fonts and @seo/components (layout.tsx)
Use next/font/google with CSS variable mode. The body font goes on --font-sans, the heading font on --font-heading. Apply both variables to the <html> tag.
IMPORTANT: Route group architecture. The root layout must NOT include Header/Footer directly. Instead, use a (main) route group with its own layout for pages that need the site Header/Footer. SEO guide pages under /t/ also go inside (main) so they share the same Header/Footer as the rest of the site.
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.
Font pairing guide:
Professional services: Inter + Oswald (clean, authoritative)
Creative/lifestyle: Lato + Playfair Display
Tech/SaaS: DM Sans + Space Grotesk
Legal/finance: Source Sans 3 + Merriweather
Phase 3: Build All Pages (Structure fixed, Visual design bespoke)
What this phase is now: the section stack order, component scaffolding (Header, Footer, section wrapper, FAQ, inner page set), and SEO infra are fixed across every client site. The visual treatment of each section is not prescribed and must be designed fresh from research/brand-identity.md, research/visual-references.md, and the design brief you produce in Phase 3.0 below.
Client sites must look distinct from one another. If your markup resembles another client site's markup, or reads as a generic corporate-agency template, you have failed Phase 3. Agents: read exemplars/claude-meter-editorial.html before writing markup, to calibrate what "distinctive, contemporary, product-first" looks like.
Appendix A (at the end of this file, formerly Phase 3d/g/h/i class dumps) contains one reference treatment: "Corporate B2B Safe Baseline". It is ONE example, not the mandate. Do not copy its Tailwind classes verbatim. Use it only if the design brief concludes that corporate-safe is actually the right answer for this client (rare; usually only for legal, finance, healthcare).
Brand identity still persists (re-read Phase 1.5i). Every user-facing string (Header wordmark, Footer copyright, metadata title/siteName/template, OpenGraph siteName, Organization JSON-LD name, WebSite JSON-LD name, email from labels, email subjects, page titles, HtmlSitemap brandName prop, page narrative copy) uses the brand name from research/brand-identity.md. The generic-domain stem only appears in URLs, email addresses, env vars, DB tables, and internal slugs. If you find yourself typing the stem into a title or JSON-LD name, you are making the rename mistake Phase 1.5i exists to prevent.
3.0. Design Brief (gate, no markup before this lands)
Before writing a single line of JSX for this site, produce research/design-brief.md answering all six items. If you can't fill an item, go back to Phase 1g and add another reference.
Design thesis: one sentence that names the visual identity (e.g. "Editorial Swiss on paper with live product mock", "Terminal-green brutalist with ASCII dividers", "Soft pastel Memphis with kinetic marquee"). Must be specific enough that two agents would produce converging palettes and motifs.
Palette: 4-6 hex codes with named roles: background, ink, accent, signal, optional muted and rule. Grounded in research/brand-identity.md. No arbitrary picks.
Type stack: at least two families (display + body), usually three (display + body + mono). All-sans is a red flag unless the brief explicitly argues for it. Justify each family against the thesis.
Signature motifs (at least 2): pick from the Phase 1g observations or invent new ones. Examples: numbered section eyebrows, hairline grid (not cards), pull quotes with oversized serif marks, paper grain overlay, italic serif for emphasis, live animated product mock, floating persistent chip, tilt parallax, copy-to-clipboard chips, kinetic type, ASCII dividers, marquee ribbons, asymmetric layouts.
Interactive moment (at least 1): hero animation tied to product state, scroll-reveal stagger, tilt-on-hover, scroll-driven sheen, copy-to-clipboard feedback, live-ticking numbers. Static pages age badly; give the site one thing that moves and rewards attention.
What this site will NOT do: list 3-5 patterns it deliberately rejects. Usually the list includes: "rounded-xl shadow-sm card grid", "centered-title + subtitle section headers", "gradient-text headlines", "generic hero with 'Primary Action' + 'Secondary Action' buttons". The point is to foreclose defaults.
Exit criteria for 3.0:research/design-brief.md exists, every item has a concrete answer, the thesis is different from every prior client's thesis (spot-check by reading 2-3 older briefs in ~/.claude/skills/setup-client-website/exemplars/briefs/ if that directory has accumulated entries).
3.1. Design DNA → Tailwind 4 theme tokens
The design brief's palette and type stack feed directly into globals.css:root + @theme inline (set up in Phase 2c). Every section after this point consumes only theme tokens: no hex codes inline, no ad-hoc font-families inline. This is the one rigid rule: if you find yourself writing style={{ color: "#E8471C" }} in a section component, stop and add it to :root as a named variable first.
Semantic token names should follow the brief's role names (e.g. --paper, --ink, --accent, --signal, --rule, --muted), not generic Tailwind defaults (--primary-50, --gray-900). Role-named tokens make the design brief and the CSS mutually readable.
3a. Header Component Blueprint
Sticky nav with logo, dropdown menus, mobile hamburger, and CTA button.
Reading order: the section stack order below is fixed for every client. The Tailwind classes inside each section are ONE reference treatment, not the mandate. After Phase 3.0 lands the design brief, reinterpret each of these seven sections in the visual identity the brief defines. Do not paste the classes verbatim. If your markup reads as a copy of this reference, it fails Phase 3.
Treat this section the way a musician reads sheet music for a jazz standard: the chord changes are fixed (hero → strip → stats → benefits → process → testimonials → CTA), the voicing and phrasing are yours.
Usage: Render in a <div className="space-y-4"> container within a bg-gray-50 section. Always include JSON-LD FAQPage schema alongside.
3g. Case Study / Wins Page Blueprint (REFERENCE EXAMPLE)
Same rule as 3d: structure fixed, visual treatment bespoke. Featured tier + additional tier is the pattern; the cards below are one rendering. If the design brief names "pull quotes with serif marks" as a motif, render case studies as pull quotes, not shadow cards. Always skip this page entirely if the client has no case studies to tell yet; a page with three fabricated wins is worse than no page.
Two tiers: featured case studies (detailed cards) and additional testimonials (grid).
3h. Book a Call / Precall Page Blueprint (REFERENCE EXAMPLE)
Structure fixed, visual treatment bespoke. The two-column scheduling + proof pattern is the pattern; classes below are one rendering. For product-led sites (Claude Meter, developer tools, self-serve SaaS), this page is often replaced by /install or /signup with a copy-to-clipboard chip; see the Claude Meter exemplar. Use your judgment from the design brief on whether book-a-call is actually the right CTA for this client.
Two-column layout: left (2/3) has video + scheduling widget, right (1/3) has testimonials sidebar.
Structure fixed, visual treatment bespoke. The section order below is the pattern. "Dark Hero + Glass Card" is one rendering; reinterpret in the brief's identity. Product-led / open-source sites may replace About entirely with a single "Why / Who built this" editorial page.
Section order: Dark Hero, Stats Bar, Founder Story (prose with photo), Team Photo, Values Grid (2-col), "Who We Serve" Checklist Grid (3-col), Contact CTA (dark bg with glass card).
Use the generateSitemap helper from @m13v/seo-components (v0.12.0+). It walks src/app for page.tsx files, skips route groups/dynamic segments/api, and applies the default priority tiers. Do NOT hand-roll a sitemap or hardcode URLs.
Every client site gets the same three integrations: PostHog for analytics, Resend for transactional email, and Neon for a lightweight relational store. The contract matches a prior site (use your reference repo). The pattern is fixed: port it verbatim, change only the brand strings and the from address.
Why these three:
PostHog: required for @seo/components NewsletterSignup + TrackedCta. The NewsletterSignup component calls window.posthog?.capture("newsletter_subscribed", ...) on success, so PostHog must be globally attached before the component mounts. Default path: hand-rolled PostHogProvider in 3.5b (shared-project sites). Only use <FullSiteAnalytics> if the site has its own dedicated PostHog project (3.5b-alt, rare).
Resend: /api/newsletter adds the subscriber to an audience and fires a welcome email. /api/contact replaces mailto: with a server-validated submission that also logs to Neon. Inbound webhook stores replies and forwards them to you@example.com.
Neon: @neondatabase/serverless for subscriber/email logs. No pool, no lifecycle. One DATABASE_URL, tagged-template SQL.
Scope note: Phase 3.5d's Book-a-Call helpers and Phase 3.5l's Cal.com event type creation are [opt-in: book-a-call]. Skip both unless the invoker requested Book-a-Call — see "Optional scope flags" above.
Hard rule for all external IDs/keys: every phc_..., audience UUID, Neon URL, Cal.com slug is pulled from keychain or live API response and substituted into the real file. Never commit a placeholder string like phc_REPLACE_ME_... or REPLACE_WITH_..._PROJECT_ID into .env.production or config.json. If the real value isn't available yet, stop Phase 3.5 here — do not proceed to Phase 6 with placeholders. Placeholders have shipped to production twice (fde10x 2026-04-19, claude-meter 2026-04-20) and silently dark-launched PostHog for the site.
framer-motion is already required by NewsletterSignup; install it even if nothing else needs it yet.
3.5b. PostHog analytics wiring: hand-rolled provider with site group (default for client sites)
Default path for every new client site (matches ~/fde10x-website/src/components/posthog-provider.tsx): a tiny hand-rolled client component that (a) initialises posthog-js, (b) sets window.posthog so legacy helpers find it, (c) calls ph.group("site", SITE_ID) + ph.register({ site: SITE_ID }) so every event is tagged with the client slug, and (d) wraps children in PHProvider + SeoAnalyticsProvider.
Why this is the default, not <FullSiteAnalytics>: every client site piggybacks on a shared PostHog project (the m13v org has an 8-project hard cap, and creating a new project per client blows past it). To filter cleanly across shared projects, every event must carry a site group tag. <FullSiteAnalytics> (shipped by @m13v/seo-components) does not accept siteId / loaded / group props — it only wires the key + host + provider context. So any site sharing a PostHog project MUST use the hand-rolled provider below. Save <FullSiteAnalytics> for standalone projects that have their own dedicated PostHog project (rare — see 3.5l for when that applies).
Why bother attaching to window.posthog: three of four client sites (Fazm, Cyrano, Mediar) silently dropped @m13v/seo-components analytics events because posthog-js was loaded via ESM but never attached to window.posthog. Library components call window.posthog?.capture(...), which is a silent no-op when window is empty. The (window as ...).posthog = posthog line below is load-bearing.
NEXT_PUBLIC_POSTHOG_KEY — the shared project'sphc_... token. Identical across every client site that piggybacks on the same project. Lookup in keychain: posthog-<shared-project-slug>-project-key (e.g. posthog-fde10x-project-key holds the shared phc_... used by fde10x, claude-meter, and any other site on that same PostHog project).
NEXT_PUBLIC_POSTHOG_SITE_ID — stable slug, differs per client (fde10x, claude-meter, assrt, …). Drives the site group tag.
3.5b-alt. <FullSiteAnalytics> (only for standalone PostHog projects)
Use this path only if the site has its own dedicated PostHog project (rare — happens when a brand was provisioned before the 8-project cap was hit, or the brand wants a fully isolated analytics surface). It skips the site group dance. Do not use this for any site that shares a PostHog project — library events won't be filterable by site.
<FullSiteAnalytics> handles window.posthog attachment and SeoAnalyticsProvider context internally, but does NOT accept siteId, loaded, or group props. If you later need site-group tagging, switch to the hand-rolled provider in 3.5b.
3.5c-verify. Verify window.posthog is set (MANDATORY)
After deploying (or running dev locally), open DevTools console on / and type:
window.posthog
It must return an object (the PostHog client instance), not undefined. If it is undefined:
@m13v/seo-components analytics (NewsletterSignup, TrackedCta, any library component that calls window.posthog?.capture) is silently broken.
Root cause is almost always: the provider initialised posthog-js via ESM but never assigned it to window.posthog. Either switch to <FullSiteAnalytics>, or add the load-bearing line shown in 3.5b-fallback.
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.
The stats pipeline at ~/social-autoposter/scripts/project_stats_json.py queries PostHog for two specific event names, scoped by properties.$host:
cta_click (underscore, no "d" at the end) — fired on any marketing CTA
schedule_click — fired on CTAs that route to a booking tool (Cal.com, Calendly, meetings.hubspot.com)
Every CTA on a new client site MUST fire cta_click. Every Book-a-Call / schedule / demo CTA MUST fire both cta_click AND schedule_click. Do not invent new event names like cta_clicked; they will be invisible to the dashboard.
Canonical helper from @m13v/seo-components (v0.14+):
Swap every hand-rolled <Link href="/contact"> for <TrackedCta href="/contact" page="home" section="hero">. Unique page + section per instance keeps PostHog funnels distinguishable.
Book-a-Call helper[opt-in: book-a-call] — skip this whole sub-section (through the end of 3.5d) unless the invoker asked for Book-a-Call. Sites without a Book CTA (free OSS tools, install-driven products) should not add BookCallLink/BookCallTracker at all. Required for any CTA that points at cal.com / calendly.com — src/lib/booking.ts + src/components/BookCallLink.tsx:
// src/lib/booking.ts"use client";
import { trackScheduleClick } from"@seo/components";
import { posthog } from"@/components/posthog-provider";
// Created via the Cal.com v2 API in Phase 3.5l (team/mediar is the shared team;// per-client slugs live under it so the Phase 6g webhook covers every client).exportconstBOOKING_URL = "https://cal.com/team/mediar/<SLUG>";
exportfunctiontrackBookingClick(opts: { section?: string; text?: string; component?: string }) {
const page = typeofwindow !== "undefined" ? window.location.pathname : undefined;
posthog?.capture("cta_click", { page, href: BOOKING_URL, text: opts.text, section: opts.section });
trackScheduleClick({
destination: BOOKING_URL, site: "<SLUG>",
section: opts.section, text: opts.text,
component: opts.component ?? "BookCallLink",
});
}
For third-party components that don't accept an onClick prop (e.g. ShimmerButton, InlineCta from @seo/components), wrap them in a client-only capture span — src/components/BookCallTracker.tsx:
<BookCallTracker section="topic-inline" component="InlineCta">
<InlineCtaheading="..."body="..."linkText="Book the call"href={BOOKING_URL} />
</BookCallTracker>
Every Book-a-Call CTA — header, hero, footer, FAQ, per-section, and any topic page — must be one of <BookCallLink> or wrapped in <BookCallTracker>. This is how the stats pipeline counts schedule_click per $host.
3.5e. NewsletterSignup — drop into (main)/layout.tsx
import { NewsletterSignup } from"@seo/components";
// ... inside the main layout return, after Footer:<NewsletterSignupdescription="Short pitch (~10 words). What does the reader get?"buttonLabel="Subscribe"successMessage="Subscribed. Check your inbox."
/>
The component POSTs to /api/newsletter by default. 2xx = success (shows success message); non-2xx expects JSON {error: "..."}. On success it calls window.posthog.capture("newsletter_subscribed", ...).
3.5f. Resend helper — src/lib/resend-server.ts
Port verbatim from ~/your-prior-site/src/lib/resend-server.ts. Change only the default from address to <Brand Name> <matt@DOMAIN>. The helper uses the REST API directly (no SDK), provides sendEmail() and addToAudience(), and fails soft when env vars are missing.
3.5g. Neon helper — src/lib/db.ts
import { neon } from"@neondatabase/serverless";
exportfunctiongetSql() {
const url = process.env.DATABASE_URL;
if (!url) thrownewError("DATABASE_URL not set");
returnneon(url);
}
Use tagged templates at call sites: const sql = getSql(); await sql`INSERT INTO ...`. No pool, no connection lifecycle.
3.5h. PostHog server helper — src/lib/posthog-server.ts (optional)
Port verbatim from your reference repo when any server route needs captureServer(). Not required for the minimum wiring.
Use the createNewsletterHandler factory from @seo/components/server (v0.18.1+). It validates email, adds to the Resend audience, sends a welcome email that includes a "Book a 15-min call" primary CTA, and gives you an onSignup hook for Neon logging + server-side PostHog capture.
import { createNewsletterHandler } from"@seo/components/server";
import { getSql } from"@/lib/db";
exportconstPOST = createNewsletterHandler({
audienceId: process.env.RESEND_AUDIENCE_ID || "<hardcode-from-phase-6d>",
fromEmail: "Matt from BRAND <matt@DOMAIN>",
brand: "BRAND",
siteUrl: "https://DOMAIN",
// REQUIRED for attribution: the per-client Cal.com team event URL created in// Phase 6g (`cal.com/team/mediar/<SLUG>`). 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/mediar/<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', 'matt@DOMAIN', ${email}, 'Welcome email', 'sent')
`;
} catch (err) { console.error("newsletter log error:", err); }
// Server-side belt-and-suspenders: also fire newsletter_subscribed from the// server. NewsletterSignup fires it client-side, but a failed hydration or// ad blocker would swallow that. Server-side guarantees the event lands.try {
const { getPostHogServer } = awaitimport("@/lib/posthog-server");
const ph = getPostHogServer();
ph?.capture({
distinctId: email,
event: "newsletter_subscribed",
properties: { source: "api/newsletter", site: "<SLUG>" },
});
await ph?.flush();
} catch (err) { console.error("newsletter posthog server-capture error:", err); }
},
});
bookingUrl is required for correct Cal.com attribution: without it, welcome-email bookings route through a generic event type and end up as client_slug='unknown' in cal_bookings. Skipping it is the bug class that let Liam's 2026-04-17 s4l booking go untracked.
Replaces mailto:hello@DOMAIN. Validates, sends notification email to you@example.com, stores inquiry in Neon, returns {ok: true} for the client form handler to render a success state.
Port ~/your-prior-site/src/app/api/webhooks/resend/route.ts. Handles email.received events, logs to Neon, forwards to you@example.com. Needed only if the client uses matt@DOMAIN as a real inbound address.
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 (site group, prefixed tables, audience, event type).
Master keychain entries (reused for every client):
Service
Keychain name
Scope
PostHog
PostHog mk0r-provisioning
Personal API key (m13v org, lists projects, creates keys)
Neon
Neon mk0r-provisioning
Account key for org-steep-sunset-62973058 (only used if you need a net-new DB, which is rare)
Resend
resend-mk0r-users
Full-access account key — this IS the runtime RESEND_API_KEY for every client site
Shared Neon pooled DATABASE_URL (e.g. neon-fde10x-pooled-url)
DATABASE_URL
resend-mk0r-users
Master Resend full-access key
RESEND_API_KEY
Per-client artifacts created by this phase (the only new things):
Artifact
Shape
Where it lives
PostHog site group tag
String slug, tagged on every event via ph.group("site", SITE_ID)
Baked into bundle as NEXT_PUBLIC_POSTHOG_SITE_ID
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 team/mediar
URL https://cal.com/team/mediar/<slug> in booking button
PostHog — reuse the shared project, isolate with a site group tag.
The provider code in 3.5b already calls ph.group("site", SITE_ID) + ph.register({ site: SITE_ID }) on load, so every event carries a site property. To filter in PostHog insights, add a site = <SLUG> property filter or group-by (PostHog's group analytics handles it natively).
Do NOT try to create a new project — the m13v org is at its cap and it returns You have reached the maximum limit of allowed projects. Instead, pick which existing project to piggyback on (new client sites default to the same shared project as fde10x / claude-meter — the phc_... token lives in posthog-fde10x-project-key).
PH_KEY=$(security find-generic-password -l "PostHog mk0r-provisioning" -w)
# List existing projects and their api_tokens so you know which phc_... to reuse.
curl -s -H "Authorization: Bearer $PH_KEY""https://us.posthog.com/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',[])]"# The shared token is typically already stored as posthog-fde10x-project-key (same phc_# for every client that piggybacks). Copy its value into the new client's .env.production.
security find-generic-password -l "posthog-fde10x-project-key" -w
There is no new posthog-<slug>-project-key keychain entry — every client on the shared project uses the same phc_.... The only per-client value is NEXT_PUBLIC_POSTHOG_SITE_ID, which is just the client slug.
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, then save a new posthog-<slug>-project-key entry. 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 (e.g. fde10x_emails, claude_meter_emails). The shared DATABASE_URL lives in keychain under neon-fde10x-pooled-url (for historical naming reasons — this IS the shared URL, not an fde10x-specific one).
SHARED_DB_URL=$(security find-generic-password -l "neon-fde10x-pooled-url" -w)
# Run DDL on the shared DB, table names prefixed with the new client's slug.
psql "$SHARED_DB_URL" -v ON_ERROR_STOP=1 <<SQL
CREATE TABLE IF NOT EXISTS <slug>_emails (
id SERIAL PRIMARY KEY,
resend_id TEXT,
direction TEXT NOT NULL DEFAULT 'outbound',
from_email TEXT,
to_email TEXT,
subject TEXT,
body_text TEXT,
body_html TEXT,
status TEXT DEFAULT 'sent',
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_<slug>_emails_created_at ON <slug>_emails(created_at DESC);
CREATE TABLE IF NOT EXISTS <slug>_contacts (
id SERIAL PRIMARY KEY,
email TEXT NOT NULL,
name TEXT,
message TEXT,
resend_ok BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_<slug>_contacts_created_at ON <slug>_contacts(created_at DESC);
SQL
The new client's Cloud Run service gets DATABASE_URL=<same shared pooled URL>. No new Neon project, no new keychain entry.
Extending the contact schema: if the site's contact form collects extra fields (outcome, timeline, budget, role, etc.), add them as nullable columns to <slug>_contactsand extend the /api/contact INSERT. Reference fde10x's schema for the outcome / timeline extension pattern.
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 = resend-mk0r-users (master full-access key). The only per-client resource is the audience (so newsletter subscribers from different sites don't mix). Domain setup is optional — do it only if the client wants a branded from address like matt@<domain>; otherwise send from the master account's default verified domain.
RESEND_KEY=$(security find-generic-password -l "resend-mk0r-users" -w)
# 1. Create the per-client audience (the only required call).
curl -s -X POST -H "Authorization: Bearer $RESEND_KEY" -H "Content-Type: application/json" \
-d '{"name":"<SLUG> newsletter"}' \
"https://api.resend.com/audiences"# → {"id":"<audience-uuid>","name":"..."}# 2. Save the audience UUID back to keychain.
security add-generic-password -U -a "you@example.com" -s "resend-<SLUG>-audience-id" -w "<audience-uuid>"
The new client's Cloud Run service gets RESEND_API_KEY=<resend-mk0r-users value> + 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 m13v 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.
Optional: branded from-address. If the client wants sends from matt@<client-domain> rather than a shared domain, add the domain + DNS records:
# Add domain (only if branded from-address is required)
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:
curl -s -X POST -H "Authorization: Bearer $RESEND_KEY" \
"https://api.resend.com/domains/<DOMAIN_ID>/verify"
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 Mediar team (teamId=50498). 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/mediar/<slug>, which is the value you put in src/components/book-call-button.tsx as BOOKING_URL.
CAL_KEY=$(security find-generic-password -l "cal-com-api-key" -w)
TEAM_ID=50498 # Mediar team# 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>", ...}}
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.
Cloudflare caveat: all Cal.com API calls must go through curl (or a tool with a matching TLS fingerprint). Python urllib gets 403/1010 Cloudflare challenges. Same applies to the Resend create-domain call above.
3.5m. Env var inventory (set during Phase 6 deploy)
Var
Build-time / Runtime
Source (shared or per-client)
Where it goes
NEXT_PUBLIC_POSTHOG_KEY
Build-arg (Docker)
Shared — same phc_... across every client on the shared project (posthog-fde10x-project-key keychain entry)
Baked into client bundle
NEXT_PUBLIC_POSTHOG_HOST
Build-arg (Docker)
Shared — https://us.i.posthog.com
Baked into client bundle
NEXT_PUBLIC_POSTHOG_SITE_ID
Build-arg (Docker)
Per-client — the client slug (fde10x, claude-meter, assrt, …)
Baked into client bundle; drives site group tag
RESEND_API_KEY
Runtime
Shared — master resend-mk0r-users full-access key
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 neon-fde10x-pooled-url keychain entry
Cloud Run --set-env-vars
Dockerfile must declare the three NEXT_PUBLIC_* vars as ARG and ENV in the builder stage so next build bakes them in. Runtime-only secrets go on the Cloud Run service, NOT the Dockerfile.
CRITICAL — strip trailing \n from every env var before setting. Follow the global rule in ~/.claude/CLAUDE.md (use echo -n and verify with gcloud run services describe). A single \n on RESEND_API_KEY will break signing and produce silent 401s.
3.5n. Exit criteria
NewsletterSignup renders in dev; submitting a valid email returns 2xx and the success message
/api/newsletter POST (curl against local dev with real Resend creds) adds the contact to Resend audience AND writes a row to <slug>_emails
/api/contact POST lands a notification email in you@example.com AND writes a row to <slug>_contacts
window.posthog is non-undefined in the browser console on / (type window.posthog in DevTools — must return an object, not undefined; see 3.5c-verify)
Clicking a TrackedCta fires a cta_clicked event visible in PostHog Live events
Submitting the NewsletterSignup fires a newsletter_subscribed event visible in PostHog Live events (confirms @m13v/seo-components library analytics path works end-to-end)
No trailing \n in any env var (verify with gcloud run services describe ... --format='value(spec.template.spec.containers[0].env)')
Phase 4: SEO Guide Pages Infrastructure
Scope of this phase: wire the infrastructure so the gsc-seo-page skill and ~/social-autoposter/seo/generate_page.py can land /t/<slug> pages. Do NOT write guide pages here. Every guide page is produced by the generator, which reads the authoritative component registry from @m13v/seo-components and handles SERP research, theme detection, and content writing.
4a. Configure next.config for guide discovery
Wrap the Next.js config with withSeoContent to enable build-time guide discovery:
At next build, this walks the content directory and generates .next/seo-guides-manifest.json so runtime code (sidebar, chat API) can read the page inventory without filesystem access on Cloud Run.
4b. Create guide index at src/app/(main)/t/page.tsx
This is the only /t/* file this skill creates. All /t/<slug>/page.tsx files are produced by the generator below.
4c. Register the site and hand off to the generator
Once 4a and 4b are in place, this skill's responsibility for SEO pages ends. For every guide page:
Register the new site in ~/social-autoposter/config.json under projects.<slug> with its repo path, production domain, and /t route. The SEO pipelines read this file to know which sites exist.
Run the generator for the first page to verify the setup end to end:
From here on, use the gsc-seo-page skill for any single-page generation, or let the cron pipelines (seo/run_gsc_pipeline.sh, seo/run_serp_pipeline.sh) drive it automatically.
The generator reads @m13v/seo-components/registry.json from the target repo's node_modules, so the component palette stays in sync with the installed version of the library. Do not hardcode a list of components in this skill or in any consumer repo.
Then add <GuideChatPanel /> from @seo/components to guide pages or the layout.
4f. Component inventory
The authoritative list of @seo/components exports is ~/seo-components/registry.json (emitted at build) and ~/seo-components/src/index.ts. Do not maintain a duplicate list in this skill or in consumer repos. The generator reads the registry directly when it writes a page.
Phase 5: Build and Verify
5a. Build
cd ~/CLIENT-website && npm run build
Fix any TypeScript or build errors. All routes must compile and generate successfully.
5b. Visual Comparison
Use the isolated browser to take full-page screenshots of the new site and compare side-by-side with the originals from Phase 1e.
Check for:
Real logo (not text placeholder)
All client/team photos present
Video embeds working (not placeholder cards)
Scheduling widget embedded (not placeholder)
Book/product images displayed
Social media icons in footer
Navigation matches original site structure
Color scheme matches client brand
Mobile responsive (test at 375px width)
5b.5. Design Review (gate before deploy)
The new site must pass an independent design review before Phase 6 (deploy). Structure check is not enough; the site must feel like the design thesis promised, not like yet another client template.
Run:
# Take full-page screenshots of the live local buildcd ~/CLIENT-website && npm run dev &
# use isolated-browser to screenshot at /, /how-it-works, /precall (or the /install route if product-led), /faq, /t# save to review/screenshots/
Then spawn the design-review skill (or design-shotgun for a broader critique) with the screenshots AND these three artifacts attached: research/visual-references.md, research/design-brief.md, and exemplars/claude-meter-editorial.html. The prompt:
Review the attached new-client-site screenshots against the design brief and visual references. Does the site deliver the thesis? Does it use the signature motifs? Does it avoid every pattern listed under "what this site will NOT do"? Flag any section that reads as generic corporate template. Identify which reference the site is visually closest to, and whether that's the intended thesis. Output: pass / fail with 3-5 specific callouts.
Fail conditions (auto-reject):
Any section renders as rounded-xl shadow cards when the brief called for hairline grid (or vice versa).
The homepage hero has no animated or interactive moment when the brief promised one.
Two or more sections use the same visual pattern as the Corporate B2B Safe Baseline (Appendix A) without intentional reason.
The site is visually indistinguishable from any existing config.json project (spot-check Mediar, Fazm, Assrt, Cyrano, FDE10X, Claude Meter).
On fail: return to Phase 3, rework the failed sections against the brief. Do not deploy a site that fails this gate just because the build passes.
5c. Analytics wiring audit
After the config.json entry lands (Phase 10), run the wiring checker to confirm PostHog + @m13v/seo-components are wired correctly on every consumer site, including this new one. It catches the silent-failure bug class where window.posthog is never set and helpers like NewsletterSignup / trackScheduleClick no-op.
cd ~/social-autoposter && python3 scripts/check_analytics_wiring.py
Exits 0 if all sites pass, 1 on any BROKEN project.
Preferred fix: mount <FullSiteAnalytics> from @m13v/seo-components (handles PostHog init + window.posthog mirror + <SeoAnalyticsProvider> in one component).
If the new site is BROKEN, step through 3.5b and 3.5c again before shipping.
FROM node:20-alpine AS base
# --- Dependencies ---
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci --ignore-scripts
# --- Builder ---
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# NEXT_PUBLIC_* vars must be present at build time because Next.js bakes them
# into the client bundle. Runtime env vars on Cloud Run are too late.
#
# Do NOT declare `ARG NEXT_PUBLIC_* / ENV NEXT_PUBLIC_*=$ARG` here. If the ARG
# is not passed at `docker build` time (and `gcloud run deploy --source .`
# does NOT pass `--set-build-env-vars` through to Dockerfile ARGs — that flag
# is buildpacks-only), the ENV ends up as an empty string, and Next.js's env
# load order puts process.env ABOVE .env.production, so the committed key is
# silently stomped. The client bundle then ships with the variable undefined,
# dead-code-eliminates the init call, and the site is dark in PostHog.
# (This footgun bit fde10x-website on 2026-04-19.)
#
# Instead, commit a `.env.production` file at repo root (see 6c.env-production
# below for the .gitignore exception). Next.js loads it automatically during
# `npm run build` inside this image. That pattern works for the full set of
# NEXT_PUBLIC_* values we care about here (all public — phc_ PostHog keys,
# site id slugs, public API hosts). Real secrets never live in NEXT_PUBLIC_*
# at all; they go through runtime env in 6d.
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
# --- Runner ---
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
ENV PORT=8080
ENV HOSTNAME="0.0.0.0"
EXPOSE 8080
CMD ["node", "server.js"]
6c.env-production. Commit .env.production with the public client-side vars
Allow the file into git with a targeted .gitignore exception:
# ~/.claude/skills/setup-client-website/templates/.gitignore append:
# env files (can opt-in for committing if needed)
.env*
# NEXT_PUBLIC_POSTHOG_KEY is a phc_ ingestion key: public by design,
# inlined into the client bundle anyway. Committing it lets the Docker
# build pick it up without --build-arg plumbing.
!.env.production
Contents of .env.production (single source of truth for build-time NEXT_PUBLIC_* values):
Literal strings like phc_REPLACE_ME_... or <SLUG> 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. All three lines (KEY, HOST, SITE_ID) are required; NEXT_PUBLIC_POSTHOG_SITE_ID is what drives the site group tag for shared-project filtering (see 3.5b).
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).
If the stem project (e.g. piastest) is shared between multiple sites, ensure each site uses its own NEXT_PUBLIC_POSTHOG_SITE_ID slug so shared PostHog projects can filter by properties.site.
6d. Deploy Cloud Run service
# Create GCP project (or reuse existing)
gcloud services enable run.googleapis.com dns.googleapis.com \
compute.googleapis.com certificatemanager.googleapis.com \
--project=GCP_PROJECT_ID
# Deploy (source-based: Cloud Build builds the Dockerfile automatically).# Use --set-env-vars (NOT --update-env-vars) so deletions also take effect.# CRITICAL: use --set-env-vars "KEY=value" with NO trailing newlines; every global env# var rule from ~/.claude/CLAUDE.md applies here.## Do NOT pass --set-build-env-vars for NEXT_PUBLIC_*. That flag is buildpacks-only# and does NOT propagate to Dockerfile ARGs. NEXT_PUBLIC_* values come from the# committed .env.production file (see 6c.env-production). This flag path was# the root cause of the 2026-04-19 fde10x-website posthog-dark incident.## --set-env-vars is for RUNTIME secrets: they never need to be baked into the# client bundle. Anything the server reads via process.env.X at request time# (database URL, Resend API key, anything server-side) goes here.
gcloud run deploy SERVICE_NAME \
--source . \
--region us-central1 \
--project GCP_PROJECT_ID \
--allow-unauthenticated \
--set-env-vars "^|^RESEND_API_KEY=re_...|RESEND_AUDIENCE_ID=...|DATABASE_URL=postgresql://..." \
--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"# Verify NEXT_PUBLIC_POSTHOG_KEY actually got baked into the client bundle# (not undefined, dead-code-eliminated away):
curl -sS https://DOMAIN/ -o /tmp/home.html
grep -oE '/_next/static/chunks/[^"\\]+\.js' /tmp/home.html | sort -u | while IFS= read -r url; doif curl -sS "https://DOMAIN$url" | grep -qE 'phc_[A-Za-z0-9]{20,}'; thenecho"OK: phc_ baked in $url"; breakfidone
6e. Set up HTTPS Load Balancer with custom domain
# Reserve static IP
gcloud compute addresses create PROJECT_NAME-ip --global --project=GCP_PROJECT_ID
STATIC_IP=$(gcloud compute addresses describe PROJECT_NAME-ip --global --project=GCP_PROJECT_ID --format="value(address)")
# Create Cloud DNS zone and A record
gcloud dns managed-zones create DNS_ZONE --dns-name="DOMAIN." \
--description="DNS zone for DOMAIN" --project=GCP_PROJECT_ID
gcloud dns record-sets create DOMAIN. --type=A --ttl=300 \
--rrdatas="$STATIC_IP" --zone=DNS_ZONE --project=GCP_PROJECT_ID
# Certificate Manager DNS authorization + managed cert
gcloud certificate-manager dns-authorizations create PROJECT_NAME-dns-auth \
--domain="DOMAIN" --project=GCP_PROJECT_ID
AUTH_CNAME=$(gcloud certificate-manager dns-authorizations describe PROJECT_NAME-dns-auth \
--project=GCP_PROJECT_ID --format="value(dnsResourceRecord.name)")
AUTH_DATA=$(gcloud certificate-manager dns-authorizations describe PROJECT_NAME-dns-auth \
--project=GCP_PROJECT_ID --format="value(dnsResourceRecord.data)")
gcloud dns record-sets create "${AUTH_CNAME}." --type=CNAME --ttl=300 \
--rrdatas="${AUTH_DATA}." --zone=DNS_ZONE --project=GCP_PROJECT_ID
gcloud certificate-manager certificates create PROJECT_NAME-cert \
--domains="DOMAIN" --dns-authorizations=PROJECT_NAME-dns-auth --project=GCP_PROJECT_ID
gcloud certificate-manager maps create PROJECT_NAME-cert-map --project=GCP_PROJECT_ID
gcloud certificate-manager maps entries create PROJECT_NAME-cert-entry \
--map=PROJECT_NAME-cert-map --certificates=PROJECT_NAME-cert \
--hostname="DOMAIN" --project=GCP_PROJECT_ID
# Serverless NEG, backend, URL map, HTTPS proxy, forwarding rule
gcloud compute network-endpoint-groups create PROJECT_NAME-neg \
--region=us-central1 --network-endpoint-type=serverless \
--cloud-run-service=SERVICE_NAME --project=GCP_PROJECT_ID
gcloud compute backend-services create PROJECT_NAME-backend --global --project=GCP_PROJECT_ID
gcloud compute backend-services add-backend PROJECT_NAME-backend --global \
--network-endpoint-group=PROJECT_NAME-neg \
--network-endpoint-group-region=us-central1 --project=GCP_PROJECT_ID
gcloud compute url-maps create PROJECT_NAME-urlmap \
--default-service=PROJECT_NAME-backend --global --project=GCP_PROJECT_ID
gcloud compute target-https-proxies create PROJECT_NAME-https-proxy \
--url-map=PROJECT_NAME-urlmap --certificate-map=PROJECT_NAME-cert-map \
--global --project=GCP_PROJECT_ID
gcloud compute forwarding-rules create PROJECT_NAME-https-rule --global \
--target-https-proxy=PROJECT_NAME-https-proxy --address=PROJECT_NAME-ip \
--ports=443 --project=GCP_PROJECT_ID
# HTTP to HTTPS redirect
gcloud compute url-maps import PROJECT_NAME-http-redirect --global --project=GCP_PROJECT_ID <<EOF
name: PROJECT_NAME-http-redirect
defaultUrlRedirect:
httpsRedirect: true
redirectResponseCode: MOVED_PERMANENTLY_DEFAULT
EOF
gcloud compute target-http-proxies create PROJECT_NAME-http-proxy \
--url-map=PROJECT_NAME-http-redirect --global --project=GCP_PROJECT_ID
gcloud compute forwarding-rules create PROJECT_NAME-http-rule --global \
--target-http-proxy=PROJECT_NAME-http-proxy --address=PROJECT_NAME-ip \
--ports=80 --project=GCP_PROJECT_ID
# Lock down Cloud Run to LB-only traffic
gcloud run services update SERVICE_NAME \
--ingress=internal-and-cloud-load-balancing \
--region=us-central1 --project=GCP_PROJECT_ID
# Resend domain DNS records (from Phase 3.5 external-service provisioning).# Without these, the welcome email + inbound webhook won't work.# Values come from Resend's "Add domain" dialog; paste them in.
gcloud dns record-sets create "send.DOMAIN." --type=TXT --ttl=300 \
--rrdatas='"v=spf1 include:amazonses.com ~all"' \
--zone=DNS_ZONE --project=GCP_PROJECT_ID
gcloud dns record-sets create "resend._domainkey.DOMAIN." --type=TXT --ttl=300 \
--rrdatas='"<DKIM value from Resend — usually 2 quoted chunks>"' \
--zone=DNS_ZONE --project=GCP_PROJECT_ID
gcloud dns record-sets create "send.DOMAIN." --type=MX --ttl=300 \
--rrdatas="10 feedback-smtp.us-east-1.amazonses.com." \
--zone=DNS_ZONE --project=GCP_PROJECT_ID
# Click "Verify" in the Resend dashboard after these propagate (~2-5 min).
6f. Create GitHub Actions workflow for CI/CD (WIF, not SA keys)
The shared project piastest enforces the org policy iam.disableServiceAccountKeyCreation. You cannot create a service account JSON key. All CI/CD auth must go through Workload Identity Federation.
Pre-step: bind the deploy SA to the existing WIF pool (one-time per new repo):
REPO="m13v/<STEM>-website"
DEPLOY_SA="<STEM>-deployer@piastest.iam.gserviceaccount.com"# Create the deploy SA if it doesn't exist
gcloud iam service-accounts create <STEM>-deployer \
--project=piastest \
--display-name="<STEM> Cloud Run deployer"# Grant the roles it needsfor role in roles/run.admin roles/cloudbuild.builds.editor roles/storage.admin roles/iam.serviceAccountUser; do
gcloud projects add-iam-policy-binding piastest \
--member="serviceAccount:${DEPLOY_SA}" \
--role="${role}"done# Bind the SA to the repo via WIF
gcloud iam service-accounts add-iam-policy-binding "${DEPLOY_SA}" \
--project=piastest \
--role="roles/iam.workloadIdentityUser" \
--member="principalSet://iam.googleapis.com/projects/882907665967/locations/global/workloadIdentityPools/github-pool/attribute.repository/${REPO}"
Existing WIF infrastructure in piastest (do not recreate):
CRITICAL: the provider has an attributeCondition that gates which GitHub orgs/repos may authenticate. Binding the SA is not enough; if the repo owner isn't allowed by the condition, auth fails with unauthorized_client: The given credential is rejected by the attribute condition BEFORE the SA binding is ever checked. This caused the fde10x-website deploy to fail on 2026-04-17 until the condition was updated.
Always pass the FULL existing condition plus your new clause; this flag replaces, not appends. The startsWith('<owner>/') form future-proofs every subsequent <owner>/*-website repo under that owner.
Create .github/workflows/deploy-cloudrun.yml:
name:DeploytoCloudRunon:push:branches: [main]
workflow_dispatch:env:REGION:us-central1PROJECT_ID:piastestSERVICE_ACCOUNT:<STEM>-deployer@piastest.iam.gserviceaccount.comWIF_PROVIDER:projects/882907665967/locations/global/workloadIdentityPools/github-pool/providers/github-providerjobs:deploy:runs-on:ubuntu-latesttimeout-minutes:20permissions:contents:readid-token:write# REQUIRED for WIFconcurrency:group:deploy-productioncancel-in-progress:falsesteps:-uses:actions/checkout@v4-id:authuses:google-github-actions/auth@v2with:workload_identity_provider:${{env.WIF_PROVIDER}}service_account:${{env.SERVICE_ACCOUNT}}-uses:google-github-actions/setup-gcloud@v2-name:DeploytoCloudRunrun:|
gcloud run deploy <STEM>-website \
--source . \
--region ${{ env.REGION }} \
--project ${{ env.PROJECT_ID }} \
--quiet
-name:Verifydeploymentrun:|
PUBLIC=$(curl -s -o /dev/null -w "%{http_code}" --max-time 30 "https://DOMAIN/" || echo "000")
echo "Public health (https://DOMAIN/): HTTP $PUBLIC"
if [ "$PUBLIC" != "200" ]; then
echo "::warning::Public health check returned $PUBLIC"
fi
No GitHub repo secrets are needed for auth. The id-token: write permission + WIF provider identity handle it. Do NOT create GCP_SA_KEY or credentials_json secrets. If the org policy is ever lifted for a new project, that alternative path is out of scope for this skill.
6g. Wire Cal.com bookings into the central stats pipeline [opt-in: book-a-call]
Skip this entire phase unless Book-a-Call was requested. Sites without a Book CTA have nothing to wire: no event type exists (3.5l was skipped), no BookCallLink exists (3.5d was skipped), no client_slug entry is needed in the webhook or project_stats.py.
Three places to touch for a new client. Skip any that already match.
1) Add the client slug's keyword to the webhook's client_slug detection. Edit ~/social-autoposter-website/src/app/api/webhooks/cal/route.ts. The detection block is an ordered if/else if. Add the new client above any branch whose keyword might overlap (e.g. FD10X team calls include "mediar" in the title because they live under the Mediar team, so the fd10x branch must precede the mediar branch):
} elseif (
eventSlug.includes("<SLUG>") ||
title.includes("<SLUG>") ||
// add brand aliases here if the title/slug use them (e.g. "pias" for fde10x)
) {
clientSlug = "<SLUG>";
}
Commit, push, and confirm the preview alias at https://social-autoposter-website.vercel.app/api/webhooks/cal returns 200 on GET.
2) Register the webhook at the cal.com team level (NOT on the personal account — every event type in the team, including this client's, will inherit the team webhook). In the browser, https://app.cal.com/settings/developer/webhooks → New → pick the relevant team (often Mediar) → Subscriber URL https://social-autoposter-website.vercel.app/api/webhooks/cal → leave all default event triggers checked (BOOKING_CREATED, BOOKING_CANCELLED, BOOKING_RESCHEDULED, BOOKING_REJECTED, BOOKING_REQUESTED, plus the rest) → Enabled → Save. A secret is optional; the current handler does not verify signatures.
3) Add "<SLUG>": "<SLUG>" to get_client_slug() in ~/social-autoposter/scripts/project_stats.py:
defget_client_slug(project_name):
"""Map project name to cal_bookings client_slug."""return {"Cyrano": "cyrano", "PieLine": "pieline", "fazm": "fazm", "S4L": "s4l",
"<SLUG>": "<SLUG>"}.get(project_name)
This file is locked with chflags uchg — temporarily unlock with chflags nouchg, edit, re-lock with chflags uchg.
Verification (one real booking then cancel):
# After the webhook is registered, book any slot on https://cal.com/team/<TEAM>/<SLUG># Then check the Neon cal_bookings row landed with client_slug=<SLUG>:export BOOKINGS_DATABASE_URL=$(grep '^BOOKINGS_DATABASE_URL=' ~/social-autoposter/.env | cut -d= -f2-)
python3 - <<PY
import re, psycopg2
conn = psycopg2.connect("$BOOKINGS_DATABASE_URL"); cur = conn.cursor()
cur.execute("SELECT cal_booking_id, client_slug, status FROM cal_bookings ORDER BY created_at DESC LIMIT 3")
for r in cur.fetchall(): print(r)
PY# Should include one row with client_slug='<SLUG>'. Cancel the booking when done.
Phase 7: Register for Tracking
7a. Add to SEO Pages Dashboard
Edit ~/social-autoposter-website/src/app/api/dashboard/seo-pages/route.ts and add the new client to CLIENT_SEO_CONFIG:
GSC domain registration, DNS TXT verification, and sitemap submission are handled programmatically via the Site Verification + Search Console APIs. See the gsc-seo-page skill, "Onboarding a New Product > Step 1", for the exact commands. Do that step now if you want the domain verified before the final checklist, or defer it to the hand-off in Phase 9. Do not use the browser flow.
Phase 8: Final Verification Checklist
Site loads at production URL
All pages render with real content (no placeholder text)
All images load (no broken images, no "VIDEO COMING SOON" placeholders)
Logo appears in header and footer
Navigation dropdowns work on desktop and mobile
Video embeds play
Scheduling widget loads
Social media icons present in footer
JSON-LD structured data on every page (validate with Google Rich Results Test)
XML sitemap accessible at /sitemap.xml
HTML sitemap page accessible at /sitemap (rendered via HtmlSitemap)
Sitemap link in Footer (under Company column) so humans can reach /sitemap without typing the URL
robots.txt accessible at /robots.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)
SeoComponentsStyles loads in <head> (check page source)
Build generates .next/seo-guides-manifest.json with correct page count
PostHog captures pageview, cta_click, schedule_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)
window.posthog defined on every public page (DevTools console: window.posthog returns an object, not undefined)
[opt-in: book-a-call only] Cal.com team webhook registered → verified with one real booking landing in Neon cal_bookings with the client's client_slug, then cancelled (see Phase 6g)
[opt-in: book-a-call only]scripts/project_stats.pyget_client_slug() maps the project name to the client's slug
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). Stats pipeline dry-run shows non-zero pageviews / cta_click counts (and non-zero bookings if book-a-call in scope)
Google Search Console ownership verified
Client added to SEO pages dashboard
Phase 9: Hand Off to the SEO Pipeline
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:
Register the domain in Google Search Console via the Site Verification + Search Console APIs (no browser).
Add the product to ~/social-autoposter/config.json with weight and the five landing_pages fields (repo, github_repo, base_url, gsc_property, product_source).
Activate com.m13v.social-gsc-seo and com.m13v.social-serp-seo via launchctl load.
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.
Prerequisites that this skill is responsible for (check before handing off):
Site deployed at https://DOMAIN, /t/ scaffolding built, sitemap at https://DOMAIN/sitemap.xml
The client repo is cloned at the path you will write into landing_pages.repo
Everything else, including the exact commands, field semantics, and checklist, lives in gsc-seo-page. Do not duplicate that content here.
Phase 10: Register in the Social Autoposter Pipeline
Phase 9 wired the SEO pipeline (/t/{slug} page generation from GSC + SERP). Phase 10 wires the posting / engagement / DM pipeline so the Reddit, Twitter, LinkedIn, GitHub, Moltbook, and Octolens jobs in ~/social-autoposter/ can mention this product, match against its topics, and respect its voice.
The canonical source is ~/social-autoposter/config.json → projects[]. Each entry drives:
Which threads/posts across all platforms are scored as relevant (matched against topics, twitter_topics, linkedin_topics, github_search_topics)
What the engagement scripts can honestly say about the product (description, features, differentiator, icp)
What voice and banned phrases apply (voice.tone, voice.never)
Links inserted into replies (website, github, links.*, and the top-level booking_link — that is the field the DM bot and dashboard actually read for the Book-a-Call URL)
10a. Build the project entry from the research brief
The research-brief.md produced in Phase 1f already has every field Phase 10 needs. Map it like this:
projects[] field
Source in research-brief.md
name
client name (lowercase slug, matches landing_pages.repo basename)
description
"Positioning angle" (one sentence)
differentiator
"3 differentiators" condensed into one sentence
icp
"ICP" section, primary persona description + JTBD
topics
"5 messaging pillars" rewritten as search-friendly keyword phrases
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.
posthog.project_id
numeric PostHog project id this site writes to (usually the shared S4L project 330744 unless the site has a dedicated project)
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 is what skill/engage-dm-replies.sh, bin/server.js, and scripts/dm_conversation.py read when deciding which cal.com 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 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 there (api_key_env), never the key itself. Actual values live in ~/social-autoposter/.env and in platform env (Vercel/Cloud Run).
10b. Inspect an existing entry as template
Before writing, read one existing entry to match the schema exactly:
Copy the shape. Do not invent fields the pipeline does not already consume.
10c. Idempotent append
Write the entry to a temp JSON file, then merge:
NEW="/tmp/new-project.json"# full entry for the client
CFG="$HOME/social-autoposter/config.json"
NAME=$(jq -r '.name'"$NEW")
# 0. Validate new entry parses
jq empty "$NEW" || { echo"invalid JSON in $NEW"; exit 1; }
# 1. Backupcp"$CFG""$CFG.bak.$(date +%Y%m%d-%H%M%S)"# 2. Abort if name already present (don't silently overwrite)if jq -e --arg n "$NAME"'.projects[] | select(.name == $n)'"$CFG" >/dev/null; thenecho"Project '$NAME' already in config.json — edit by hand or remove the existing entry first."exit 1
fi# 3. Append, validate, show diff, then write
jq --argjson new "$(cat "$NEW")"'.projects += [$new]'"$CFG" > "$CFG.tmp"
jq empty "$CFG.tmp" || { echo"merge produced invalid JSON"; rm"$CFG.tmp"; exit 1; }
diff <(jq '.projects | map(.name)'"$CFG") <(jq '.projects | map(.name)'"$CFG.tmp")
mv"$CFG.tmp""$CFG"
Review the diff before mv. If it shows anything other than a single project-name addition, abort.
10d. Merge with Phase 9's landing_pages block
If Phase 9 already ran and added a partial entry with only landing_pages + weight, do not duplicate. Instead, detect the existing entry and merge Phase 10's fields into it:
jq --arg n "$NAME" --argjson new "$(cat "$NEW")"'
.projects |= map(if .name == $n then . + $new else . end)
'"$CFG" > "$CFG.tmp" && mv"$CFG.tmp""$CFG"
Phase 9 fields (weight, landing_pages) are never overwritten by Phase 10. Phase 10 fields (description, topics, features, voice, etc.) are never touched by Phase 9.
10e. Smoke test
After writing, confirm the posting pipeline can see the new project:
cd ~/social-autoposter && python3 -c "
import json
d = json.load(open('config.json'))
p = next((x for x in d['projects'] if x['name'] == '$NAME'), None)
assert p, 'not found'
for k in ['description','topics','features','voice','website']:
assert k in p, f'missing {k}'
print('ok:', p['name'], '-', p['description'][:80])
"
Then do a single dry-run of one engagement script to make sure the new entry does not crash the matcher (replace engage-reddit.sh with whichever script is least expensive to dry-run):
If the dry-run surfaces a schema mismatch, fix the new entry before letting real runs fire.
10f. Exit criteria
Phase 10 is done when all of these are true:
jq '.projects[] | select(.name=="<client>")' config.json returns a full entry (not just landing_pages)
topics, twitter_topics, linkedin_topics, and features all trace to the research brief
voice.never matches the brief's "Banned clichés" list verbatim
At least one engagement script dry-run completes without a schema error
config.json.bak.* backup exists in case of rollback
Only after Phase 10 passes is the client actually "in the pipeline." Until then, they have a website but no posting, no engagement, no DM outreach, and no Octolens mention tracking.
Quick Reference: File Structure
~/CLIENT-website/
public/
images/
logo.png # Client logo
founder.png # Founder headshot
team-photo.png # Team photo
client-1.png # Client headshots for testimonials
client-2.png
product-1.png # Product images
book-covers-strip.png # Product gallery (if applicable)
src/
app/
globals.css # Tailwind 4 theme with brand colors
layout.tsx # Root layout: fonts, metadata, JSON-LD ONLY (no Header/Footer)
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
site-sidebar.tsx # Server component wrapping walkPages() (optional)
site-sidebar-client.tsx # Client component wrapping SitemapSidebar (optional)